editorconfig_parser/
lib.rs

1use std::path::{Path, PathBuf};
2
3use globset::{Glob, GlobMatcher};
4
5#[derive(Debug, Default, Clone)]
6pub struct EditorConfig {
7    /// Set to true to tell the core not to check any higher directory for EditorConfig settings for on the current filename.
8    root: bool,
9
10    sections: Vec<EditorConfigSection>,
11
12    /// The base directory for resolving absolute paths.
13    cwd: Option<PathBuf>,
14}
15
16impl EditorConfig {
17    pub fn root(&self) -> bool {
18        self.root
19    }
20
21    pub fn sections(&self) -> &[EditorConfigSection] {
22        &self.sections
23    }
24
25    pub fn cwd(&self) -> Option<&Path> {
26        self.cwd.as_deref()
27    }
28
29    /// Sets the current working directory for resolving absolute paths.
30    pub fn with_cwd<P: AsRef<Path>>(mut self, cwd: P) -> Self {
31        self.cwd = Some(cwd.as_ref().to_path_buf());
32        self
33    }
34}
35
36/// <https://spec.editorconfig.org/index.html>
37#[derive(Debug, Default, Clone)]
38pub struct EditorConfigSection {
39    /// Section Name: the string between the beginning `[` and the ending `]`.
40    pub name: String,
41
42    pub matcher: Option<GlobMatcher>,
43
44    pub properties: EditorConfigProperties,
45}
46
47#[derive(Debug, Default, Clone, Eq, PartialEq)]
48pub enum EditorConfigProperty<T> {
49    #[default]
50    None,
51    Unset,
52    Value(T),
53}
54
55#[derive(Debug, Default, Clone, Eq, PartialEq)]
56pub struct EditorConfigProperties {
57    /// Set to tab or space to use tabs or spaces for indentation, respectively.
58    /// Option tab implies that an indentation is to be filled by as many hard tabs as possible, with the rest of the indentation filled by spaces.
59    /// A non-normative explanation can be found in the indentation_ section.
60    /// The values are case-insensitive.
61    pub indent_style: EditorConfigProperty<IndentStyle>,
62
63    /// Set to a whole number defining the number of columns used for each indentation level and the width of soft tabs (when supported).
64    /// If this equals tab, the indent_size shall be set to the tab size, which should be tab_width (if specified); else, the tab size set by the editor.
65    /// The values are case-insensitive.
66    pub indent_size: EditorConfigProperty<usize>,
67
68    /// Set to a whole number defining the number of columns used to represent a tab character.
69    /// This defaults to the value of indent_size and should not usually need to be specified.
70    pub tab_width: EditorConfigProperty<usize>,
71
72    /// Set to lf, cr, or crlf to control how line breaks are represented.
73    /// The values are case-insensitive.
74    pub end_of_line: EditorConfigProperty<EndOfLine>,
75
76    /// Set to latin1, utf-8, utf-8-bom, utf-16be or utf-16le to control the character set.
77    /// Use of utf-8-bom is discouraged.
78    /// The values are case-insensitive.
79    pub charset: EditorConfigProperty<Charset>,
80
81    /// Set to true to remove all whitespace characters preceding newline characters in the file and false to ensure it doesn’t.
82    pub trim_trailing_whitespace: EditorConfigProperty<bool>,
83
84    /// Set to true ensure file ends with a newline when saving and false to ensure it doesn’t.
85    /// Editors must not insert newlines in empty files when saving those files, even if insert_final_newline = true.
86    pub insert_final_newline: EditorConfigProperty<bool>,
87
88    /// Prettier print width.
89    /// Not part of spec <https://github.com/editorconfig/editorconfig-vscode/issues/53#issuecomment-462432616>
90    /// But documented in <https://prettier.io/docs/next/configuration#editorconfig>
91    pub max_line_length: EditorConfigProperty<MaxLineLength>,
92}
93
94#[derive(Debug, Clone, Copy, Eq, PartialEq)]
95pub enum MaxLineLength {
96    /// A numeric line length limit
97    Number(usize),
98    /// Line length limit is disabled
99    Off,
100}
101
102#[derive(Debug, Clone, Copy, Eq, PartialEq)]
103pub enum IndentStyle {
104    Tab,
105    Space,
106}
107
108#[derive(Debug, Clone, Copy, Eq, PartialEq)]
109pub enum EndOfLine {
110    Lf,
111    Cr,
112    Crlf,
113}
114
115#[derive(Debug, Clone, Copy, Eq, PartialEq)]
116pub enum Charset {
117    Latin1,
118    Utf8,
119    Utf8bom,
120    Utf16be,
121    Utf16le,
122}
123
124impl EditorConfig {
125    /// <https://spec.editorconfig.org/index.html#id6>
126    pub fn parse(source_text: &str) -> Self {
127        // EditorConfig files are in an INI-like file format.
128        // To read an EditorConfig file, take one line at a time, from beginning to end.
129        // For each line:
130        // 1. Remove all leading and trailing whitespace.
131        // 2. Process the remaining text as specified for its type below.
132        let mut root = false;
133        let mut sections = vec![];
134        let mut preamble = true;
135        for line in source_text.lines() {
136            let line = line.trim();
137            // Blank: Contains nothing. Blank lines are ignored.
138            if line.is_empty() {
139                continue;
140            }
141            // Comment: starts with a ; or a #. Comment lines are ignored.
142            if line.starts_with([';', '#']) {
143                continue;
144            }
145            // Parse `root`. Must be specified in the preamble. The value is case-insensitive.
146            if preamble
147                && !line.starts_with('[')
148                && let Some((key, value)) = line.split_once('=')
149                && key.trim_end() == "root"
150                && value.trim_start().eq_ignore_ascii_case("true")
151            {
152                root = true;
153            }
154            // Section Header: starts with a [ and ends with a ]. These lines define globs;
155            if let Some(line) = line.strip_prefix('[') {
156                preamble = false;
157                if let Some(line) = line.strip_suffix(']') {
158                    let name = line.to_string();
159                    let matcher = Glob::new(&name).ok().map(|glob| glob.compile_matcher());
160                    sections.push(EditorConfigSection {
161                        name,
162                        matcher,
163                        ..EditorConfigSection::default()
164                    });
165                }
166            }
167            // Key-Value Pair (or Pair): contains a key and a value, separated by an `=`.
168            if let Some(section) = sections.last_mut()
169                && let Some((key, value)) = line.split_once('=')
170            {
171                let value = value.trim_start();
172                let properties = &mut section.properties;
173                match key.trim_end() {
174                    "indent_style" => {
175                        properties.indent_style = IndentStyle::parse(value);
176                    }
177                    "indent_size" => {
178                        properties.indent_size = EditorConfigProperty::<usize>::parse(value);
179                    }
180                    "tab_width" => {
181                        properties.tab_width = EditorConfigProperty::<usize>::parse(value);
182                    }
183                    "end_of_line" => {
184                        properties.end_of_line = EditorConfigProperty::<EndOfLine>::parse(value);
185                    }
186                    "charset" => {
187                        properties.charset = EditorConfigProperty::<Charset>::parse(value);
188                    }
189                    "trim_trailing_whitespace" => {
190                        properties.trim_trailing_whitespace =
191                            EditorConfigProperty::<bool>::parse(value);
192                    }
193                    "insert_final_newline" => {
194                        properties.insert_final_newline =
195                            EditorConfigProperty::<bool>::parse(value);
196                    }
197                    "max_line_length" => {
198                        properties.max_line_length =
199                            EditorConfigProperty::<MaxLineLength>::parse(value);
200                    }
201                    _ => {}
202                }
203            }
204        }
205
206        Self { root, sections, cwd: None }
207    }
208
209    /// Resolve a given path and return the resolved properties.
210    /// If `cwd` is set, absolute paths will be resolved relative to `cwd`.
211    pub fn resolve(&self, path: &Path) -> EditorConfigProperties {
212        let path =
213            if let Some(cwd) = &self.cwd { path.strip_prefix(cwd).unwrap_or(path) } else { path };
214        let mut properties = EditorConfigProperties::default();
215        for section in &self.sections {
216            if section.matcher.as_ref().is_some_and(|matcher| matcher.is_match(path)) {
217                properties.override_with(&section.properties);
218            }
219        }
220        properties
221    }
222}
223
224impl<T: Copy> EditorConfigProperty<T> {
225    fn override_with(&mut self, other: &Self) {
226        match other {
227            Self::Value(value) => {
228                *self = Self::Value(*value);
229            }
230            Self::Unset => {
231                *self = Self::None;
232            }
233            Self::None => {}
234        }
235    }
236}
237
238impl EditorConfigProperties {
239    fn override_with(&mut self, other: &Self) {
240        self.indent_style.override_with(&other.indent_style);
241        self.indent_size.override_with(&other.indent_size);
242        self.tab_width.override_with(&other.tab_width);
243        self.end_of_line.override_with(&other.end_of_line);
244        self.charset.override_with(&other.charset);
245        self.trim_trailing_whitespace.override_with(&other.trim_trailing_whitespace);
246        self.insert_final_newline.override_with(&other.insert_final_newline);
247        self.max_line_length.override_with(&other.max_line_length);
248    }
249}
250
251impl EditorConfigProperty<usize> {
252    fn parse(s: &str) -> Self {
253        if s.eq_ignore_ascii_case("unset") {
254            Self::Unset
255        } else {
256            s.parse::<usize>().map_or(Self::None, EditorConfigProperty::Value)
257        }
258    }
259}
260
261impl EditorConfigProperty<bool> {
262    fn parse(s: &str) -> Self {
263        if s.eq_ignore_ascii_case("true") {
264            EditorConfigProperty::Value(true)
265        } else if s.eq_ignore_ascii_case("false") {
266            EditorConfigProperty::Value(false)
267        } else if s.eq_ignore_ascii_case("unset") {
268            EditorConfigProperty::Unset
269        } else {
270            EditorConfigProperty::None
271        }
272    }
273}
274
275impl IndentStyle {
276    fn parse(s: &str) -> EditorConfigProperty<Self> {
277        if s.eq_ignore_ascii_case("tab") {
278            EditorConfigProperty::Value(Self::Tab)
279        } else if s.eq_ignore_ascii_case("space") {
280            EditorConfigProperty::Value(Self::Space)
281        } else if s.eq_ignore_ascii_case("unset") {
282            EditorConfigProperty::Unset
283        } else {
284            EditorConfigProperty::None
285        }
286    }
287}
288
289impl EditorConfigProperty<EndOfLine> {
290    fn parse(s: &str) -> Self {
291        if s.eq_ignore_ascii_case("lf") {
292            Self::Value(EndOfLine::Lf)
293        } else if s.eq_ignore_ascii_case("cr") {
294            Self::Value(EndOfLine::Cr)
295        } else if s.eq_ignore_ascii_case("crlf") {
296            Self::Value(EndOfLine::Crlf)
297        } else if s.eq_ignore_ascii_case("unset") {
298            Self::Unset
299        } else {
300            Self::None
301        }
302    }
303}
304
305impl EditorConfigProperty<Charset> {
306    fn parse(s: &str) -> Self {
307        if s.eq_ignore_ascii_case("utf-8") {
308            Self::Value(Charset::Utf8)
309        } else if s.eq_ignore_ascii_case("latin1") {
310            Self::Value(Charset::Latin1)
311        } else if s.eq_ignore_ascii_case("utf-16be") {
312            Self::Value(Charset::Utf16be)
313        } else if s.eq_ignore_ascii_case("utf-16le") {
314            Self::Value(Charset::Utf16le)
315        } else if s.eq_ignore_ascii_case("utf-8-bom") {
316            Self::Value(Charset::Utf8bom)
317        } else if s.eq_ignore_ascii_case("unset") {
318            Self::Unset
319        } else {
320            Self::None
321        }
322    }
323}
324
325impl EditorConfigProperty<MaxLineLength> {
326    fn parse(s: &str) -> Self {
327        if s.eq_ignore_ascii_case("off") {
328            Self::Value(MaxLineLength::Off)
329        } else if s.eq_ignore_ascii_case("unset") {
330            Self::Unset
331        } else if let Ok(n) = s.parse::<usize>() {
332            Self::Value(MaxLineLength::Number(n))
333        } else {
334            Self::None
335        }
336    }
337}