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