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 @@ -153,6 +153,8 @@ 'PhutilDocblockParserTestCase' => 'parser/__tests__/PhutilDocblockParserTestCase.php', 'PhutilEditDistanceMatrix' => 'utils/PhutilEditDistanceMatrix.php', 'PhutilEditDistanceMatrixTestCase' => 'utils/__tests__/PhutilEditDistanceMatrixTestCase.php', + 'PhutilEditorConfig' => 'parser/PhutilEditorConfig.php', + 'PhutilEditorConfigTestCase' => 'parser/__tests__/PhutilEditorConfigTestCase.php', 'PhutilEmailAddress' => 'parser/PhutilEmailAddress.php', 'PhutilEmailAddressTestCase' => 'parser/__tests__/PhutilEmailAddressTestCase.php', 'PhutilErrorHandler' => 'error/PhutilErrorHandler.php', @@ -388,7 +390,9 @@ 'phutil_get_library_root' => 'moduleutils/moduleutils.php', 'phutil_get_library_root_for_path' => 'moduleutils/moduleutils.php', 'phutil_get_signal_name' => 'future/exec/execx.php', + 'phutil_glob_to_regex' => 'utils/utils.php', 'phutil_implode_html' => 'markup/render.php', + 'phutil_ini_decode' => 'utils/utils.php', 'phutil_is_hiphop_runtime' => 'utils/utils.php', 'phutil_is_utf8' => 'utils/utf8.php', 'phutil_is_utf8_slowly' => 'utils/utf8.php', @@ -565,6 +569,7 @@ 'PhutilDirectedScalarGraph' => 'AbstractDirectedGraph', 'PhutilDocblockParserTestCase' => 'PhutilTestCase', 'PhutilEditDistanceMatrixTestCase' => 'PhutilTestCase', + 'PhutilEditorConfigTestCase' => 'PhutilTestCase', 'PhutilEmailAddressTestCase' => 'PhutilTestCase', 'PhutilErrorHandlerTestCase' => 'PhutilTestCase', 'PhutilErrorTrap' => 'Phobject', diff --git a/src/parser/PhutilEditorConfig.php b/src/parser/PhutilEditorConfig.php new file mode 100644 --- /dev/null +++ b/src/parser/PhutilEditorConfig.php @@ -0,0 +1,89 @@ +rootPath = $root; + + $paths = id(new FileFinder($root)) + ->withType('f') + ->withName('.editorconfig') + ->find(); + + foreach ($paths as $path) { + $this->addEditorConfigFile(Filesystem::resolvePath($path, $root)); + } + } + + protected function addEditorConfigFile($path) { + $file = Filesystem::readFile($path); + $config = phutil_ini_decode($file); + + unset($config['root']); + $this->validateEditorConfig($config); + + $path = dirname($path); + $this->configs[$path] = array(); + + foreach ($config as $key => $value) { + if (preg_match('@/@', $key)) { + $this->configs[$path][phutil_glob_to_regex('**/'.$key)] = $value; + } else if (preg_match('@^/@', $key)) { + $this->configs[$path][phutil_glob_to_regex(substr($key, 1))] = $value; + } else { + $this->configs[$path][phutil_glob_to_regex($key)] = $value; + } + } + } + + public static function validateEditorConfig(array $config) { + $spec = PhutilTypeSpec::newFromString('map>'); + $spec->check($config); + } + + /** + * Return the paths of all EditorConfig files that were found. + * + * @return list + */ + public function getPaths() { + $paths = array(); + + foreach (array_keys($this->configs) as $path) { + $paths[] = $path.'/.editorconfig'; + } + + return $paths; + } + + /** + * Return the root path. + * + * @return string + */ + public function getRootPath() { + return $this->rootPath; + } + + public function getConfig($path, $key) { + foreach ($this->configs as $config_path => $config) { + if (Filesystem::isDescendant($path, $config_path)) { + foreach ($config as $config_key => $config_value) { + + } + } + } + } + +} diff --git a/src/parser/__tests__/PhutilEditorConfigTestCase.php b/src/parser/__tests__/PhutilEditorConfigTestCase.php new file mode 100644 --- /dev/null +++ b/src/parser/__tests__/PhutilEditorConfigTestCase.php @@ -0,0 +1,50 @@ + array( + dirname(__FILE__).'/editorconfig/.editorconfig', + dirname(__FILE__).'/editorconfig/subdir1/.editorconfig', + dirname(__FILE__).'/editorconfig/subdir2/.editorconfig', + ), + dirname(__FILE__).'/editorconfig/subdir1' => array( + dirname(__FILE__).'/editorconfig/subdir1/.editorconfig', + ), + dirname(__FILE__).'/editorconfig/subdir2' => array( + dirname(__FILE__).'/editorconfig/subdir2/.editorconfig', + ), + dirname(__FILE__).'/../../../' => array( + Filesystem::resolvePath('.editorconfig', dirname(__FILE__).'/../../..'), + dirname(__FILE__).'/editorconfig/.editorconfig', + dirname(__FILE__).'/editorconfig/subdir1/.editorconfig', + dirname(__FILE__).'/editorconfig/subdir2/.editorconfig', + ), + ); + + foreach ($tests as $input => $expect) { + $editorconfig = new PhutilEditorConfig($input); + $this->assertEqual($expect, $editorconfig->getPaths()); + } + } + + public function testGetRootPath() { + $tests = array( + dirname(__FILE__) => dirname(__FILE__), + dirname(__FILE__).'/editorconfig/subdir1' + => dirname(__FILE__).'/editorconfig/subdir1', + dirname(__FILE__).'/editorconfig/subdir2' + => dirname(__FILE__).'/editorconfig/subdir2', + ); + + foreach ($tests as $input => $expect) { + $editorconfig = new PhutilEditorConfig($input); + $this->assertEqual($expect, $editorconfig->getRootPath()); + } + } + +} diff --git a/src/parser/__tests__/editorconfig/.editorconfig b/src/parser/__tests__/editorconfig/.editorconfig new file mode 100644 diff --git a/src/parser/__tests__/editorconfig/subdir1/.editorconfig b/src/parser/__tests__/editorconfig/subdir1/.editorconfig new file mode 100644 diff --git a/src/parser/__tests__/editorconfig/subdir2/.editorconfig b/src/parser/__tests__/editorconfig/subdir2/.editorconfig new file mode 100644 --- /dev/null +++ b/src/parser/__tests__/editorconfig/subdir2/.editorconfig @@ -0,0 +1 @@ +root = true 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 @@ -544,6 +544,35 @@ } } + public function testPhutilINIDecode() { + $valid_cases = array( + '' => array(), + "[foo]\nbar = baz\n" => array('foo' => array('bar' => 'baz')), + "; Comment\n[foo]\nbar = baz\n" => array('foo' => array('bar' => 'baz')), + "# Comment\n[foo]\nbar = baz\n" => array('foo' => array('bar' => 'baz')), + ); + + foreach ($valid_cases as $input => $expect) { + $result = phutil_ini_decode($input); + $this->assertEqual($expect, $result, 'phutil_ini_decode('.$input.')'); + } + + $invalid_cases = array( + '[', + "[\nfoo\n]\nbar = baz\n", + ); + + foreach ($invalid_cases as $input) { + $caught = null; + try { + phutil_ini_decode($input); + } catch (Exception $ex) { + $caught = $ex; + } + $this->assertTrue($caught instanceof Exception); + } + } + public function testCensorCredentials() { $cases = array( '' => '', @@ -574,5 +603,72 @@ } } + public function testGlobToRegex() { + $cases = array( + '' => array( + array(''), + array('f', '/'), + ), + '*' => array( + array('foo'), + array('foo/', '/foo'), + ), + '**' => array( + array('foo', 'foo/', '/foo', 'foo/bar/baz'), + array(), + ), + 'foo.*' => array( + array('foo.php', 'foo.a', 'foo.'), + array('fooo.php', 'foo.php/foo'), + ), + 'fo?' => array( + array('foo', 'fot'), + array('fooo', 'ffoo', 'fo/'), + ), + 'fo{o,t}' => array( + array('foo', 'fot'), + array('fob', 'fo/'), + ), + 'foo(bar|foo)' => array( + array('foo(bar|foo)'), + array('foobar', 'foofoo'), + ), + 'foo,bar' => array( + array('foo,bar'), + array('foo', 'bar'), + ), + 'fo{o,\\,}' => array( + array('foo', 'fo,'), + array(), + ), + 'fo{o,\\\\}' => array( + array('foo', 'fo\\'), + array(), + ), + '/foo' => array( + array('/foo'), + array('foo'), + ), + ); + + foreach ($cases as $input => $expect) { + list($matches, $no_matches) = $expect; + + $regex = phutil_glob_to_regex($input); + PhutilTypeSpec::newFromString('regex')->check($regex); + + foreach ($matches as $match) { + $this->assertTrue( + (bool)preg_match($regex, $match), + pht('Expecting "%s" to match "%s".', $regex, $match)); + } + + foreach ($no_matches as $no_match) { + $this->assertFalse( + (bool)preg_match($regex, $no_match), + pht('Expecting "%s" not to match "%s".', $regex, $no_match)); + } + } + } } diff --git a/src/utils/utils.php b/src/utils/utils.php --- a/src/utils/utils.php +++ b/src/utils/utils.php @@ -1065,6 +1065,30 @@ return $result; } +/** + * Decode an INI string. + * + * @param string + * @return mixed + */ +function phutil_ini_decode($string) { + if (function_exists('parse_ini_string')) { + $result = @parse_ini_string($string, true); + } else { + $tmp = new TempFile('ini'); + Filesystem::writeFile($tmp, $string); + $full_path = (string)$tmp; + + $result = @parse_ini_file($full_path, true); + } + + if ($result === false) { + throw new Exception('Invalid INI.'); + } + + return $result; +} + /** * Attempt to censor any plaintext credentials from a string. @@ -1080,3 +1104,65 @@ function phutil_censor_credentials($string) { return preg_replace(',(?<=://)([^/@\s]+)(?=@|$),', 'xxxxx', $string); } + +/** + * Returns a regular expression which is equivalent to the given glob pattern. + * + * This function was adapted from + * https://github.com/symfony/Finder/blob/master/Glob.php. + * + * @param string A glob pattern. + * @return regex + */ +function phutil_glob_to_regex($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; + } + + return '#^'.$regex.'$#'; +}