diff --git a/src/filesystem/FileFinder.php b/src/filesystem/FileFinder.php index 0bdfbdc..6b1dbbb 100644 --- a/src/filesystem/FileFinder.php +++ b/src/filesystem/FileFinder.php @@ -1,338 +1,365 @@ withType('f') * ->withSuffix('php') * ->find(); * * @task create Creating a File Query * @task config Configuring File Queries * @task exec Executing the File Query * @task internal Internal */ final class FileFinder extends Phobject { private $root; private $exclude = array(); private $paths = array(); private $name = array(); private $suffix = array(); + private $nameGlobs = array(); private $type; private $generateChecksums = false; private $followSymlinks; private $forceMode; /** * Create a new FileFinder. * * @param string Root directory to find files beneath. * @return this * @task create */ public function __construct($root) { $this->root = rtrim($root, '/'); } /** * @task config */ public function excludePath($path) { $this->exclude[] = $path; return $this; } /** * @task config */ public function withName($name) { $this->name[] = $name; return $this; } /** * @task config */ public function withSuffix($suffix) { $this->suffix[] = $suffix; return $this; } /** * @task config */ public function withPath($path) { $this->paths[] = $path; return $this; } /** * @task config */ public function withType($type) { $this->type = $type; return $this; } /** * @task config */ public function withFollowSymlinks($follow) { $this->followSymlinks = $follow; return $this; } /** * @task config */ public function setGenerateChecksums($generate) { $this->generateChecksums = $generate; return $this; } public function getGenerateChecksums() { return $this->generateChecksums; } + public function withNameGlob($pattern) { + $this->nameGlobs[] = $pattern; + return $this; + } + /** * @task config * @param string Either "php", "shell", or the empty string. */ public function setForceMode($mode) { $this->forceMode = $mode; return $this; } /** * @task internal */ public function validateFile($file) { if ($this->name) { $matches = false; foreach ($this->name as $curr_name) { if (basename($file) === $curr_name) { $matches = true; break; } } if (!$matches) { return false; } } + if ($this->nameGlobs) { + $name = basename($file); + + $matches = false; + foreach ($this->nameGlobs as $glob) { + $glob = addcslashes($glob, '\\'); + if (fnmatch($glob, $name)) { + $matches = true; + break; + } + } + + if (!$matches) { + return false; + } + } + if ($this->suffix) { $matches = false; foreach ($this->suffix as $suffix) { $suffix = addcslashes($suffix, '\\?*'); $suffix = '*.'.$suffix; if (fnmatch($suffix, $file)) { $matches = true; break; } } if (!$matches) { return false; } } if ($this->paths) { $matches = false; foreach ($this->paths as $path) { if (fnmatch($path, $this->root.'/'.$file)) { $matches = true; break; } } if (!$matches) { return false; } } $fullpath = $this->root.'/'.ltrim($file, '/'); if (($this->type == 'f' && is_dir($fullpath)) || ($this->type == 'd' && !is_dir($fullpath))) { return false; } return true; } /** * @task internal */ private function getFiles($dir) { $found = Filesystem::listDirectory($this->root.'/'.$dir, true); $files = array(); if (strlen($dir) > 0) { $dir = rtrim($dir, '/').'/'; } foreach ($found as $filename) { // Only exclude files whose names match relative to the root. if ($dir == '') { $matches = true; foreach ($this->exclude as $exclude_path) { if (fnmatch(ltrim($exclude_path, './'), $dir.$filename)) { $matches = false; break; } } if (!$matches) { continue; } } if ($this->validateFile($dir.$filename)) { $files[] = $dir.$filename; } if (is_dir($this->root.'/'.$dir.$filename)) { foreach ($this->getFiles($dir.$filename) as $file) { $files[] = $file; } } } return $files; } /** * @task exec */ public function find() { $files = array(); if (!is_dir($this->root) || !is_readable($this->root)) { throw new Exception( pht( "Invalid %s root directory specified ('%s'). Root directory ". "must be a directory, be readable, and be specified with an ". "absolute path.", __CLASS__, $this->root)); } if ($this->forceMode == 'shell') { $php_mode = false; } else if ($this->forceMode == 'php') { $php_mode = true; } else { $php_mode = (phutil_is_windows() || !Filesystem::binaryExists('find')); } if ($php_mode) { $files = $this->getFiles(''); } else { $args = array(); $command = array(); $command[] = 'find'; if ($this->followSymlinks) { $command[] = '-L'; } $command[] = '.'; if ($this->exclude) { $command[] = $this->generateList('path', $this->exclude).' -prune'; $command[] = '-o'; } if ($this->type) { $command[] = '-type %s'; $args[] = $this->type; } if ($this->name) { $command[] = $this->generateList('name', $this->name, 'name'); } if ($this->suffix) { $command[] = $this->generateList('name', $this->suffix, 'suffix'); } if ($this->paths) { $command[] = $this->generateList('path', $this->paths); } + if ($this->nameGlobs) { + $command[] = $this->generateList('name', $this->nameGlobs); + } + $command[] = '-print0'; array_unshift($args, implode(' ', $command)); list($stdout) = newv('ExecFuture', $args) ->setCWD($this->root) ->resolvex(); $stdout = trim($stdout); if (!strlen($stdout)) { return array(); } $files = explode("\0", $stdout); // On OSX/BSD, find prepends a './' to each file. foreach ($files as $key => $file) { // When matching directories, we can get "." back in the result set, // but this isn't an interesting result. if ($file == '.') { unset($files[$key]); continue; } if (substr($files[$key], 0, 2) == './') { $files[$key] = substr($files[$key], 2); } } } if (!$this->generateChecksums) { return $files; } else { $map = array(); foreach ($files as $line) { $fullpath = $this->root.'/'.ltrim($line, '/'); if (is_dir($fullpath)) { $map[$line] = null; } else { $map[$line] = md5_file($fullpath); } } return $map; } } /** * @task internal */ private function generateList( $flag, array $items, - $mode = 'path') { + $mode = 'glob') { foreach ($items as $key => $item) { - // If the mode is not "path" mode, we're going to escape glob characters + // If the mode is not "glob" mode, we're going to escape glob characters // in the pattern. Otherwise, we escape only backslashes. - if ($mode === 'path') { + if ($mode === 'glob') { $item = addcslashes($item, '\\'); } else { $item = addcslashes($item, '\\*?'); } if ($mode === 'suffix') { $item = '*.'.$item; } $item = (string)csprintf('%s %s', '-'.$flag, $item); $items[$key] = $item; } $items = implode(' -o ', $items); return '"(" '.$items.' ")"'; } } diff --git a/src/filesystem/__tests__/FileFinderTestCase.php b/src/filesystem/__tests__/FileFinderTestCase.php index 1ddbfad..f7214bd 100644 --- a/src/filesystem/__tests__/FileFinderTestCase.php +++ b/src/filesystem/__tests__/FileFinderTestCase.php @@ -1,212 +1,232 @@ excludePath('./exclude') ->excludePath('subdir.txt'); } public function testFinderWithChecksums() { $this->assertFinder( pht('Basic Checksums'), $this->newFinder() ->setGenerateChecksums(true) ->withType('f') ->withPath('*') ->withSuffix('txt'), array( '.hidden.txt' => 'b6cfc9ce9afe12b258ee1c19c235aa27', 'file.txt' => '725130ba6441eadb4e5d807898e0beae', 'include_dir.txt/anotherfile.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'include_dir.txt/subdir.txt/alsoinclude.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'test.txt' => 'aea46212fa8b8d0e0e6aa34a15c9e2f5', )); } public function testFinderWithoutChecksums() { $this->assertFinder( pht('Basic No Checksums'), $this->newFinder() ->withType('f') ->withPath('*') ->withSuffix('txt'), array( '.hidden.txt', 'file.txt', 'include_dir.txt/anotherfile.txt', 'include_dir.txt/subdir.txt/alsoinclude.txt', 'test.txt', )); } public function testFinderWithFilesAndDirectories() { $this->assertFinder( pht('With Files And Directories'), $this->newFinder() ->setGenerateChecksums(true) ->withPath('*') ->withSuffix('txt'), array( '.hidden.txt' => 'b6cfc9ce9afe12b258ee1c19c235aa27', 'file.txt' => '725130ba6441eadb4e5d807898e0beae', 'include_dir.txt' => null, 'include_dir.txt/anotherfile.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'include_dir.txt/subdir.txt' => null, 'include_dir.txt/subdir.txt/alsoinclude.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', 'test.txt' => 'aea46212fa8b8d0e0e6aa34a15c9e2f5', )); } public function testFinderWithDirectories() { $this->assertFinder( pht('Just Directories'), $this->newFinder() ->withType('d'), array( 'include_dir.txt', 'include_dir.txt/subdir.txt', )); } public function testFinderWithPath() { $this->assertFinder( pht('With Path'), $this->newFinder() ->setGenerateChecksums(true) ->withType('f') ->withPath('*/include_dir.txt/subdir.txt/alsoinclude.txt') ->withSuffix('txt'), array( 'include_dir.txt/subdir.txt/alsoinclude.txt' => '91e5c1ad76ff229c6456ac92e74e1d9f', )); } public function testFinderWithNames() { $this->assertFinder( pht('With Names'), $this->newFinder() ->withType('f') ->withPath('*') ->withName('test'), array( 'include_dir.txt/subdir.txt/test', 'include_dir.txt/test', 'test', )); } public function testFinderWithNameAndSuffix() { $this->assertFinder( pht('With Name and Suffix'), $this->newFinder() ->withType('f') ->withName('alsoinclude.txt') ->withSuffix('txt'), array( 'include_dir.txt/subdir.txt/alsoinclude.txt', )); } public function testFinderWithGlobMagic() { // Fill a temporary directory with all this magic garbage so we don't have // to check a bunch of files with backslashes in their names into version // control. $tmp_dir = Filesystem::createTemporaryDirectory(); $crazy_magic = array( 'backslash\\.\\*', 'star-*.*', 'star-*.txt', 'star.t*t', 'star.tesseract', ); foreach ($crazy_magic as $sketchy_path) { Filesystem::writeFile($tmp_dir.'/'.$sketchy_path, '.'); } $this->assertFinder( pht('Glob Magic, Literal .t*t'), $this->newFinder($tmp_dir) ->withType('f') ->withSuffix('t*t'), array( 'star.t*t', )); $this->assertFinder( pht('Glob Magic, .tesseract'), $this->newFinder($tmp_dir) ->withType('f') ->withSuffix('tesseract'), array( 'star.tesseract', )); $this->assertFinder( pht('Glob Magic, Name'), $this->newFinder($tmp_dir) ->withType('f') ->withName('star-*'), array()); $this->assertFinder( pht('Glob Magic, Name + Suffix'), $this->newFinder($tmp_dir) ->withType('f') ->withName('star-*.*'), array( 'star-*.*', )); $this->assertFinder( pht('Glob Magic, Backslash Suffix'), $this->newFinder($tmp_dir) ->withType('f') ->withSuffix('\\*'), array( 'backslash\\.\\*', )); + + $this->assertFinder( + pht('Glob Magic, With Globs'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withNameGlob('star-*'), + array( + 'star-*.*', + 'star-*.txt', + )); + + $this->assertFinder( + pht('Glob Magic, With Globs + Suffix'), + $this->newFinder($tmp_dir) + ->withType('f') + ->withNameGlob('star-*') + ->withSuffix('txt'), + array( + 'star-*.txt', + )); } private function assertFinder($label, FileFinder $finder, $expect) { $modes = array( 'php', 'shell', ); foreach ($modes as $mode) { $actual = id(clone $finder) ->setForceMode($mode) ->find(); if ($finder->getGenerateChecksums()) { ksort($actual); } else { sort($actual); } $this->assertEqual( $expect, $actual, pht('Test Case "%s" in Mode "%s"', $label, $mode)); } } }