Skip to main content

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    /// Quote type for string literals.
94    /// Not part of spec but supported by Prettier, VSCode, IntelliJ, and others as a domain-specific extension.
95    pub quote_type: EditorConfigProperty<QuoteType>,
96}
97
98#[derive(Debug, Clone, Copy, Eq, PartialEq)]
99pub enum MaxLineLength {
100    /// A numeric line length limit
101    Number(usize),
102    /// Line length limit is disabled
103    Off,
104}
105
106#[derive(Debug, Clone, Copy, Eq, PartialEq)]
107pub enum IndentStyle {
108    Tab,
109    Space,
110}
111
112#[derive(Debug, Clone, Copy, Eq, PartialEq)]
113pub enum EndOfLine {
114    Lf,
115    Cr,
116    Crlf,
117}
118
119#[derive(Debug, Clone, Copy, Eq, PartialEq)]
120pub enum Charset {
121    Latin1,
122    Utf8,
123    Utf8bom,
124    Utf16be,
125    Utf16le,
126}
127
128#[derive(Debug, Clone, Copy, Eq, PartialEq)]
129pub enum QuoteType {
130    Single,
131    Double,
132    Auto,
133}
134
135impl EditorConfig {
136    /// <https://spec.editorconfig.org/index.html#id6>
137    pub fn parse(source_text: &str) -> Self {
138        // EditorConfig files are in an INI-like file format.
139        // To read an EditorConfig file, take one line at a time, from beginning to end.
140        // For each line:
141        // 1. Remove all leading and trailing whitespace.
142        // 2. Process the remaining text as specified for its type below.
143        let mut root = false;
144        let mut sections = vec![];
145        let mut preamble = true;
146        for line in source_text.lines() {
147            let line = line.trim();
148            // Blank: Contains nothing. Blank lines are ignored.
149            if line.is_empty() {
150                continue;
151            }
152            // Comment: starts with a ; or a #. Comment lines are ignored.
153            if line.starts_with([';', '#']) {
154                continue;
155            }
156            // Parse `root`. Must be specified in the preamble. The value is case-insensitive.
157            if preamble
158                && !line.starts_with('[')
159                && let Some((key, value)) = line.split_once('=')
160                && key.trim_end() == "root"
161                && value.trim_start().eq_ignore_ascii_case("true")
162            {
163                root = true;
164            }
165            // Section Header: starts with a [ and ends with a ]. These lines define globs;
166            if let Some(line) = line.strip_prefix('[') {
167                preamble = false;
168                if let Some(line) = line.strip_suffix(']') {
169                    let name = line.to_string();
170                    let matcher = Glob::new(&name).ok().map(|glob| glob.compile_matcher());
171                    sections.push(EditorConfigSection {
172                        name,
173                        matcher,
174                        ..EditorConfigSection::default()
175                    });
176                }
177            }
178            // Key-Value Pair (or Pair): contains a key and a value, separated by an `=`.
179            if let Some(section) = sections.last_mut()
180                && let Some((key, value)) = line.split_once('=')
181            {
182                let value = value.trim_start();
183                let properties = &mut section.properties;
184                match key.trim_end() {
185                    "indent_style" => {
186                        properties.indent_style = IndentStyle::parse(value);
187                    }
188                    "indent_size" => {
189                        properties.indent_size = EditorConfigProperty::<usize>::parse(value);
190                    }
191                    "tab_width" => {
192                        properties.tab_width = EditorConfigProperty::<usize>::parse(value);
193                    }
194                    "end_of_line" => {
195                        properties.end_of_line = EditorConfigProperty::<EndOfLine>::parse(value);
196                    }
197                    "charset" => {
198                        properties.charset = EditorConfigProperty::<Charset>::parse(value);
199                    }
200                    "trim_trailing_whitespace" => {
201                        properties.trim_trailing_whitespace =
202                            EditorConfigProperty::<bool>::parse(value);
203                    }
204                    "insert_final_newline" => {
205                        properties.insert_final_newline =
206                            EditorConfigProperty::<bool>::parse(value);
207                    }
208                    "max_line_length" => {
209                        properties.max_line_length =
210                            EditorConfigProperty::<MaxLineLength>::parse(value);
211                    }
212                    "quote_type" => {
213                        properties.quote_type = QuoteType::parse(value);
214                    }
215                    _ => {}
216                }
217            }
218        }
219
220        Self { root, sections, cwd: None }
221    }
222
223    /// Resolve a given path and return the resolved properties.
224    /// If `cwd` is set, absolute paths will be resolved relative to `cwd`.
225    pub fn resolve(&self, path: &Path) -> EditorConfigProperties {
226        let path =
227            if let Some(cwd) = &self.cwd { path.strip_prefix(cwd).unwrap_or(path) } else { path };
228        let mut properties = EditorConfigProperties::default();
229        for section in &self.sections {
230            if section.matcher.as_ref().is_some_and(|matcher| matcher.is_match(path)) {
231                properties.override_with(&section.properties);
232            }
233        }
234        properties
235    }
236}
237
238impl<T: Copy> EditorConfigProperty<T> {
239    fn override_with(&mut self, other: &Self) {
240        match other {
241            Self::Value(value) => {
242                *self = Self::Value(*value);
243            }
244            Self::Unset => {
245                *self = Self::None;
246            }
247            Self::None => {}
248        }
249    }
250}
251
252impl EditorConfigProperties {
253    fn override_with(&mut self, other: &Self) {
254        self.indent_style.override_with(&other.indent_style);
255        self.indent_size.override_with(&other.indent_size);
256        self.tab_width.override_with(&other.tab_width);
257        self.end_of_line.override_with(&other.end_of_line);
258        self.charset.override_with(&other.charset);
259        self.trim_trailing_whitespace.override_with(&other.trim_trailing_whitespace);
260        self.insert_final_newline.override_with(&other.insert_final_newline);
261        self.max_line_length.override_with(&other.max_line_length);
262        self.quote_type.override_with(&other.quote_type);
263    }
264}
265
266impl EditorConfigProperty<usize> {
267    fn parse(s: &str) -> Self {
268        if s.eq_ignore_ascii_case("unset") {
269            Self::Unset
270        } else {
271            s.parse::<usize>().map_or(Self::None, EditorConfigProperty::Value)
272        }
273    }
274}
275
276impl EditorConfigProperty<bool> {
277    fn parse(s: &str) -> Self {
278        if s.eq_ignore_ascii_case("true") {
279            EditorConfigProperty::Value(true)
280        } else if s.eq_ignore_ascii_case("false") {
281            EditorConfigProperty::Value(false)
282        } else if s.eq_ignore_ascii_case("unset") {
283            EditorConfigProperty::Unset
284        } else {
285            EditorConfigProperty::None
286        }
287    }
288}
289
290impl IndentStyle {
291    fn parse(s: &str) -> EditorConfigProperty<Self> {
292        if s.eq_ignore_ascii_case("tab") {
293            EditorConfigProperty::Value(Self::Tab)
294        } else if s.eq_ignore_ascii_case("space") {
295            EditorConfigProperty::Value(Self::Space)
296        } else if s.eq_ignore_ascii_case("unset") {
297            EditorConfigProperty::Unset
298        } else {
299            EditorConfigProperty::None
300        }
301    }
302}
303
304impl EditorConfigProperty<EndOfLine> {
305    fn parse(s: &str) -> Self {
306        if s.eq_ignore_ascii_case("lf") {
307            Self::Value(EndOfLine::Lf)
308        } else if s.eq_ignore_ascii_case("cr") {
309            Self::Value(EndOfLine::Cr)
310        } else if s.eq_ignore_ascii_case("crlf") {
311            Self::Value(EndOfLine::Crlf)
312        } else if s.eq_ignore_ascii_case("unset") {
313            Self::Unset
314        } else {
315            Self::None
316        }
317    }
318}
319
320impl EditorConfigProperty<Charset> {
321    fn parse(s: &str) -> Self {
322        if s.eq_ignore_ascii_case("utf-8") {
323            Self::Value(Charset::Utf8)
324        } else if s.eq_ignore_ascii_case("latin1") {
325            Self::Value(Charset::Latin1)
326        } else if s.eq_ignore_ascii_case("utf-16be") {
327            Self::Value(Charset::Utf16be)
328        } else if s.eq_ignore_ascii_case("utf-16le") {
329            Self::Value(Charset::Utf16le)
330        } else if s.eq_ignore_ascii_case("utf-8-bom") {
331            Self::Value(Charset::Utf8bom)
332        } else if s.eq_ignore_ascii_case("unset") {
333            Self::Unset
334        } else {
335            Self::None
336        }
337    }
338}
339
340impl QuoteType {
341    fn parse(s: &str) -> EditorConfigProperty<Self> {
342        if s.eq_ignore_ascii_case("single") {
343            EditorConfigProperty::Value(Self::Single)
344        } else if s.eq_ignore_ascii_case("double") {
345            EditorConfigProperty::Value(Self::Double)
346        } else if s.eq_ignore_ascii_case("auto") {
347            EditorConfigProperty::Value(Self::Auto)
348        } else if s.eq_ignore_ascii_case("unset") {
349            EditorConfigProperty::Unset
350        } else {
351            EditorConfigProperty::None
352        }
353    }
354}
355
356impl EditorConfigProperty<MaxLineLength> {
357    fn parse(s: &str) -> Self {
358        if s.eq_ignore_ascii_case("off") {
359            Self::Value(MaxLineLength::Off)
360        } else if s.eq_ignore_ascii_case("unset") {
361            Self::Unset
362        } else if let Ok(n) = s.parse::<usize>() {
363            Self::Value(MaxLineLength::Number(n))
364        } else {
365            Self::None
366        }
367    }
368}