1<?php 2 3/** 4 * Parser for [[http://editorconfig.org/ | EditorConfig]] files. 5 */ 6final class PhutilEditorConfig extends Phobject { 7 8 const CHARSET = 'charset'; 9 const END_OF_LINE = 'end_of_line'; 10 const INDENT_SIZE = 'indent_size'; 11 const INDENT_STYLE = 'indent_style'; 12 const FINAL_NEWLINE = 'insert_final_newline'; 13 const LINE_LENGTH = 'max_line_length'; 14 const TAB_WIDTH = 'tab_width'; 15 const TRAILING_WHITESPACE = 'trim_trailing_whitespace'; 16 17 /** 18 * Valid properties. 19 * 20 * See http://editorconfig.org/#file-format-details. 21 */ 22 private static $knownProperties = array( 23 self::CHARSET => array( 24 'latin1', 25 'utf-8', 26 'utf-8-bom', 27 'utf-16be', 28 'utf-16le', 29 ), 30 self::END_OF_LINE => array('lf', 'cr', 'crlf'), 31 self::INDENT_SIZE => 'int|string', 32 self::INDENT_STYLE => array('space', 'tab'), 33 self::FINAL_NEWLINE => 'bool', 34 self::LINE_LENGTH => 'int', 35 self::TAB_WIDTH => 'int', 36 self::TRAILING_WHITESPACE => 'bool', 37 ); 38 39 private $root; 40 41 /** 42 * Constructor. 43 * 44 * @param string The root directory. 45 */ 46 public function __construct($root) { 47 $this->root = $root; 48 } 49 50 /** 51 * Get the specified EditorConfig property for the specified path. 52 * 53 * @param string 54 * @param string 55 * @return wild 56 */ 57 public function getProperty($path, $key) { 58 if (!idx(self::$knownProperties, $key)) { 59 throw new InvalidArgumentException(pht('Invalid EditorConfig property.')); 60 } 61 62 $props = $this->getProperties($path); 63 64 switch ($key) { 65 case self::INDENT_SIZE: 66 if (idx($props, self::INDENT_SIZE) === null && 67 idx($props, self::INDENT_STYLE) === 'tab') { 68 return 'tab'; 69 } else if (idx($props, self::INDENT_SIZE) === 'tab' && 70 idx($props, self::TAB_WIDTH) === null) { 71 return idx($props, self::TAB_WIDTH); 72 } 73 break; 74 75 case self::TAB_WIDTH: 76 if (idx($props, self::TAB_WIDTH) === null && 77 idx($props, self::INDENT_SIZE) !== null && 78 idx($props, self::INDENT_SIZE) !== 'tab') { 79 return idx($props, self::INDENT_SIZE); 80 } 81 break; 82 } 83 84 return idx($props, $key); 85 } 86 87 /** 88 * Get the EditorConfig properties for the specified path. 89 * 90 * Returns a map containing all of the EditorConfig properties which apply 91 * to the specified path. The following rules are applied when processing 92 * EditorConfig files: 93 * 94 * - If a glob does not contain `/`, it can match a path in any subdirectory. 95 * - If the first character of a glob is `/`, it will only match files in the 96 * same directory as the `.editorconfig` file. 97 * - Properties and values are case-insensitive. 98 * - Unknown properties will be silently ignored. 99 * - Values are not validated against the specification (this may change in 100 * the future). 101 * - Invalid glob patterns will be silently ignored. 102 * 103 * @param string 104 * @return map<string, wild> 105 */ 106 public function getProperties($path) { 107 $configs = $this->getEditorConfigs($path); 108 $matches = array(); 109 110 // Normalize directory separators to "/". The ".editorconfig" standard 111 // uses only "/" as a directory separator, not "\". 112 $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); 113 114 foreach ($configs as $config) { 115 list($path_prefix, $editorconfig) = $config; 116 117 // Normalize path separators, as above. 118 $path_prefix = str_replace(DIRECTORY_SEPARATOR, '/', $path_prefix); 119 120 foreach ($editorconfig as $glob => $properties) { 121 if (!$glob) { 122 continue; 123 } 124 125 if (strpos($glob, '/') === false) { 126 $glob = '**/'.$glob; 127 } else if (strncmp($glob, '/', 0)) { 128 $glob = substr($glob, 1); 129 } 130 131 $glob = $path_prefix.'/'.$glob; 132 try { 133 if (!phutil_fnmatch($glob, $path)) { 134 continue; 135 } 136 } catch (Exception $ex) { 137 // Invalid glob pattern... ignore it. 138 continue; 139 } 140 141 foreach ($properties as $property => $value) { 142 $property = strtolower($property); 143 144 if (!idx(self::$knownProperties, $property)) { 145 // Unknown property... ignore it. 146 continue; 147 } 148 149 if (is_string($value)) { 150 $value = strtolower($value); 151 } 152 if ($value === '') { 153 $value = null; 154 } 155 $matches[$property] = $value; 156 } 157 } 158 } 159 160 return $matches; 161 } 162 163 /** 164 * Returns the EditorConfig files which affect the specified path. 165 * 166 * Find and parse all `.editorconfig` files between the specified path and 167 * the root directory. The results are returned in the same order that they 168 * should be matched. 169 * 170 * return list<pair<string, map>> 171 */ 172 private function getEditorConfigs($path) { 173 $configs = array(); 174 175 $found_root = false; 176 $paths = Filesystem::walkToRoot($path, $this->root); 177 foreach ($paths as $path) { 178 $file = $path.'/.editorconfig'; 179 180 if (!Filesystem::pathExists($file)) { 181 continue; 182 } 183 184 $contents = Filesystem::readFile($file); 185 $config = phutil_ini_decode($contents); 186 187 if (idx($config, 'root') === true) { 188 $found_root = true; 189 } 190 unset($config['root']); 191 array_unshift($configs, array($path, $config)); 192 193 if ($found_root) { 194 break; 195 } 196 } 197 198 return $configs; 199 } 200 201} 202