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', 'PhutilEmptyAuthAdapter' => 'auth/PhutilEmptyAuthAdapter.php', @@ -594,6 +596,7 @@ 'PhutilDisqusAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilDocblockParserTestCase' => 'PhutilTestCase', 'PhutilEditDistanceMatrixTestCase' => 'PhutilTestCase', + 'PhutilEditorConfigTestCase' => 'PhutilTestCase', 'PhutilEmailAddressTestCase' => 'PhutilTestCase', 'PhutilEmptyAuthAdapter' => 'PhutilAuthAdapter', 'PhutilErrorHandlerTestCase' => 'PhutilTestCase', 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,180 @@ + array('latin1', 'utf-8', 'utf-8-bom', 'utf-16be', 'utf-16le'), + 'end_of_line' => array('lf', 'cr', 'crlf'), + 'indent_size' => 'int|string', + 'indent_style' => array('space', 'tab'), + 'insert_final_newline' => 'bool', + 'max_line_length' => 'int', + 'tab_width' => 'int', + 'trim_trailing_whitespace' => 'bool', + ); + + private $root; + + /** + * Constructor. + * + * @param string The root directory. + */ + public function __construct($root) { + $this->root = $root; + } + + /** + * Get the specified EditorConfig property for the specified path. + * + * @param string + * @param string + * @return wild + */ + public function getProperty($path, $key) { + if (!idx(self::$knownProperties, $key)) { + throw new InvalidArgumentException(pht('Invalid EditorConfig property.')); + } + + $props = $this->getProperties($path); + + switch ($key) { + case 'indent_size': + if (idx($props, 'indent_size') === null && + idx($props, 'indent_style') === 'tab') { + return 'tab'; + } else if (idx($props, 'indent_size') === 'tab' && + idx($props, 'tab_width') === null) { + return idx($props, 'tab_width'); + } + break; + + case 'tab_width': + if (idx($props, 'tab_width') === null && + idx($props, 'indent_size') !== null && + idx($props, 'indent_size') !== 'tab') { + return idx($props, 'indent_size'); + } + break; + } + + return idx($props, $key); + } + + /** + * Get the EditorConfig properties for the specified path. + * + * Returns a map containing all of the EditorConfig properties which apply + * to the specified path. The following rules are applied when processing + * EditorConfig files: + * + * - If a glob does not contain `/`, it can match a path in any subdirectory. + * - If the first character of a glob is `/`, it will only match files in the + * same directory as the `.editorconfig` file. + * - Properties and values are case-insensitive. + * - Unknown properties will be silently ignored. + * - Values are not validated against the specification (this may change in + * the future). + * - Invalid glob patterns will be silently ignored. + * + * @param string + * @return map + */ + public function getProperties($path) { + $configs = $this->getEditorConfigs($path); + $matches = array(); + + foreach ($configs as $config) { + list($path_prefix, $editorconfig) = $config; + + foreach ($editorconfig as $glob => $properties) { + if (!$glob) { + continue; + } + + if (strpos($glob, '/') === false) { + $glob = '**/'.$glob; + } else if (strncmp($glob, '/', 0)) { + $glob = substr($glob, 1); + } + + $glob = $path_prefix.'/'.$glob; + try { + if (!phutil_fnmatch($glob, $path)) { + continue; + } + } catch (Exception $ex) { + // Invalid glob pattern... ignore it. + continue; + } + + foreach ($properties as $property => $value) { + $property = strtolower($property); + + if (!idx(self::$knownProperties, $property)) { + // Unknown property... ignore it. + continue; + } + + if (is_string($value)) { + $value = strtolower($value); + } + if ($value === '') { + $value = null; + } + $matches[$property] = $value; + } + } + } + + return $matches; + } + + /** + * Returns the EditorConfig files which affect the specified path. + * + * Find and parse all `.editorconfig` files between the specified path and + * the root directory. The results are returned in the same order that they + * should be matched. + * + * return list> + */ + private function getEditorConfigs($path) { + $configs = array(); + $found_root = false; + $root = $this->root; + + do { + $path = dirname($path); + $file = $path.'/.editorconfig'; + + if (!Filesystem::pathExists($file)) { + continue; + } + + $contents = Filesystem::readFile($file); + $config = phutil_ini_decode($contents); + + if (idx($config, 'root') === true) { + $found_root = true; + } + unset($config['root']); + array_unshift($configs, array($path, $config)); + + if ($found_root) { + break; + } + } while ($path != $root && Filesystem::isDescendant($path, $root)); + + return $configs; + } + +} 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,106 @@ +getTestFile()); + + $tests = array( + 'default' => array( + array( + 'indent_style' => 'space', + 'indent_size' => 2, + 'charset' => 'utf-8', + 'trim_trailing_whitespace' => true, + 'insert_final_newline' => true, + ), + array(), + ), + 'file' => array( + array( + 'indent_style' => 'space', + 'indent_size' => 3, + 'charset' => 'utf-8', + 'trim_trailing_whitespace' => true, + 'insert_final_newline' => true, + ), + array(), + ), + 'file.txt' => array( + array( + 'indent_style' => 'space', + 'indent_size' => 3, + 'charset' => 'latin1', + 'trim_trailing_whitespace' => true, + 'insert_final_newline' => true, + ), + array(), + ), + 'externals/README' => array( + array( + 'indent_style' => null, + 'indent_size' => null, + 'charset' => 'utf-8', + 'trim_trailing_whitespace' => false, + 'insert_final_newline' => false, + ), + array(), + ), + 'subdir/file' => array( + array( + 'indent_style' => 'tab', + 'indent_size' => 3, + 'charset' => 'utf-8-bom', + 'trim_trailing_whitespace' => true, + 'insert_final_newline' => true, + ), + array(), + ), + 'empty/file' => array( + array(), + array( + 'indent_style' => null, + 'indent_size' => null, + 'charset' => null, + 'trim_trailing_whitespace' => null, + 'insert_final_newline' => null, + ), + ), + ); + + foreach ($tests as $path => $expected) { + list($properties, $property) = $expected; + $property = array_merge($properties, $property); + + $this->assertEqual( + $properties, + $parser->getProperties($this->getTestFile($path))); + + foreach ($property as $key => $value) { + $this->assertEqual( + $value, + $parser->getProperty($this->getTestFile($path), $key)); + } + } + + $invalid_properties = array( + 'invalid', + ); + + foreach ($invalid_properties as $invalid_property) { + $caught = null; + try { + $parser->getProperty('', $invalid_property); + } catch (Exception $ex) { + $caught = $ex; + } + + $this->assertTrue($caught instanceof InvalidArgumentException); + } + } + + private function getTestFile($path = null) { + return dirname(__FILE__).'/editorconfig/'.$path; + } + +} diff --git a/src/parser/__tests__/editorconfig/.editorconfig b/src/parser/__tests__/editorconfig/.editorconfig new file mode 100644 --- /dev/null +++ b/src/parser/__tests__/editorconfig/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[file*] +indent_size = 3 + +[*.txt] +charset = latin1 + +[{invalid_glob] +indent_size = 1 + +[externals/**] +indent_style = +indent_size = +trim_trailing_whitespace = false +insert_final_newline = false diff --git a/src/parser/__tests__/editorconfig/empty/.editorconfig b/src/parser/__tests__/editorconfig/empty/.editorconfig new file mode 100644 --- /dev/null +++ b/src/parser/__tests__/editorconfig/empty/.editorconfig @@ -0,0 +1 @@ +root = true diff --git a/src/parser/__tests__/editorconfig/externals/.editorconfig b/src/parser/__tests__/editorconfig/externals/.editorconfig new file mode 100644 diff --git a/src/parser/__tests__/editorconfig/subdir/.editorconfig b/src/parser/__tests__/editorconfig/subdir/.editorconfig new file mode 100644 --- /dev/null +++ b/src/parser/__tests__/editorconfig/subdir/.editorconfig @@ -0,0 +1,3 @@ +[*] +indent_style = tab +charset = utf-8-bom