diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -398,6 +398,7 @@ 'phutil_escape_uri' => 'markup/render.php', 'phutil_escape_uri_path_component' => 'markup/render.php', 'phutil_exit' => 'utils/utils.php', + 'phutil_fnmatch' => 'utils/utils.php', 'phutil_format_bytes' => 'utils/viewutils.php', 'phutil_format_relative_time' => 'utils/viewutils.php', 'phutil_format_relative_time_detailed' => 'utils/viewutils.php', diff --git a/src/utils/__tests__/PhutilUtilsTestCase.php b/src/utils/__tests__/PhutilUtilsTestCase.php --- a/src/utils/__tests__/PhutilUtilsTestCase.php +++ b/src/utils/__tests__/PhutilUtilsTestCase.php @@ -616,4 +616,65 @@ phutil_var_export(new PhutilTestPhobject())); } + public function testFnmatch() { + $cases = array( + '' => array( + array(''), + array('.', '/'), + ), + '*' => array( + array('file'), + array('dir/', '/dir'), + ), + '**' => array( + array('file', 'dir/', '/dir', 'dir/subdir/file'), + array(), + ), + '**/file' => array( + array('file', 'dir/file', 'dir/subdir/file', 'dir/subdir/subdir/file'), + array('file/', 'file/dir'), + ), + 'file.*' => array( + array('file.php', 'file.a', 'file.'), + array('files.php', 'file.php/blah'), + ), + 'fo?' => array( + array('foo', 'fot'), + array('fooo', 'ffoo', 'fo/', 'foo/'), + ), + 'fo{o,t}' => array( + array('foo', 'fot'), + array('fob', 'fo/', 'foo/'), + ), + 'fo{o,\\,}' => array( + array('foo', 'fo,'), + array('foo/', 'fo,/'), + ), + 'fo{o,\\\\}' => array( + array('foo', 'fo\\'), + array('foo/', 'fo\\/'), + ), + '/foo' => array( + array('/foo'), + array('foo', '/foo/'), + ), + ); + + foreach ($cases as $input => $expect) { + list($matches, $no_matches) = $expect; + + foreach ($matches as $match) { + $this->assertTrue( + phutil_fnmatch($input, $match), + pht('Expecting "%s" to match "%s".', $input, $match)); + } + + foreach ($no_matches as $no_match) { + $this->assertFalse( + phutil_fnmatch($input, $no_match), + pht('Expecting "%s" not to match "%s".', $input, $no_match)); + } + } + } + } diff --git a/src/utils/utils.php b/src/utils/utils.php --- a/src/utils/utils.php +++ b/src/utils/utils.php @@ -1121,3 +1121,68 @@ // Let PHP handle everything else. return var_export($var, true); } + + +/** + * An improved version of `fnmatch`. + * + * @param string A glob pattern. + * @param string A path. + * @return bool + */ +function phutil_fnmatch($glob, $path) { + // Modify the glob to allow `**/` to match files in the root directory. + $glob = str_replace('**/', '{,*/,**/}', $glob); + + $escaping = false; + $in_curlies = 0; + $regex = ''; + + for ($i = 0; $i < strlen($glob); $i++) { + $char = $glob[$i]; + $next_char = ($i < strlen($glob) - 1) ? $glob[$i + 1] : null; + + if (in_array($char, array('.', '(', ')', '|', '+', '^', '$'))) { + $regex .= "\\$char"; + } else if ($char === '*') { + if ($escaping) { + $regex .= '\\*'; + } else { + if ($next_char === '*') { + $regex .= '.*'; + } else { + $regex .= '[^/]*'; + } + } + } else if ($char === '?') { + $regex .= $escaping ? '\\?' : '[^/]'; + } else if ($char === '{') { + $regex .= $escaping ? '\\{' : '('; + if (!$escaping) { + ++$in_curlies; + } + } else if ($char === '}' && $in_curlies) { + $regex .= $escaping ? '}' : ')'; + if (!$escaping) { + --$in_curlies; + } + } else if ($char === ',' && $in_curlies) { + $regex .= $escaping ? ',' : '|'; + } else if ($char === '\\') { + if ($escaping) { + $regex .= '\\\\'; + $escaping = false; + } else { + $escaping = true; + } + + continue; + } else { + $regex .= $char; + } + $escaping = false; + } + + $regex = '#\A'.$regex.'\z#'; + return (bool)preg_match($regex, $path); +}