editorconfig_core/
lib.rs

1//! An EditorConfig Core passing all the [editorconfig-core-test] tests.
2//!
3//! # Examples
4//!
5//! ```no_run
6//! use editorconfig_core::properties;
7//!
8//! // Let's define the property we want to extract.
9//!
10//! enum EndOfLine { Cr, Crlf, Lf }
11//!
12//! impl EndOfLine {
13//!     const KEY: &str = "end_of_line";
14//!
15//!     fn from_str<S: AsRef<str>>(s: S) -> Option<Self> {
16//!         match s.as_ref() {
17//!             "cr" => Some(Self::Cr),
18//!             "crlf" => Some(Self::Crlf),
19//!             "lf" => Some(Self::Lf),
20//!             _ => None,
21//!         }
22//!     }
23//! }
24//!
25//! // Now, fetch the properties for our file.
26//!
27//! // Must be a full, normalized, valid unicode path.
28//! let path = "/home/myself/README.md";
29//!
30//! let mut properties = properties(path).unwrap();
31//!
32//! // Discard properties that was unset.
33//! properties.retain(|_key, value| !value.eq_ignore_ascii_case("unset"));
34//!
35//! // Extract the property.
36//! let eof = properties.get(EndOfLine::KEY).and_then(EndOfLine::from_str);
37//! ```
38//!
39//! # Notes
40//!
41//! - All the keys are already lowercased via `str::to_lowercase`.
42//! - The values are kept in their original form, except for the values of the ["Supported"](https://editorconfig.org/#supported-properties)
43//!   properties.
44//!
45//! [editorconfig-core-test]: https://github.com/editorconfig/editorconfig-core-test
46
47mod glob;
48mod version;
49
50use std::collections::HashMap;
51use std::convert::identity;
52use std::fs::File;
53use std::io::{self, BufRead as _, BufReader};
54use std::path::Path;
55
56use crate::glob::Glob;
57pub use crate::version::Version;
58
59/// Max. supported EditorConfig version.
60pub const MAX_VERSION: Version = Version { major: 0, minor: 17, patch: 2 };
61
62#[derive(Debug)]
63pub enum Error {
64    Parse,
65    InvalidPath,
66    Io(io::Error),
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Options<'a> {
71    /// Another name for EditorConfig files (defaults to ".editorconfig").
72    pub file_name: &'a str,
73    /// EditorConfig version to use (defaults to [`MAX_VERSION`]).
74    pub version: Version,
75}
76
77impl<'a> Default for Options<'a> {
78    fn default() -> Self {
79        Self { file_name: ".editorconfig", version: MAX_VERSION }
80    }
81}
82
83/// All the keys are lowercased, values are kept in their original form, except
84/// for the values of "Supported" properties.
85pub type Properties = HashMap<String, String>;
86
87/// Retreives the properties for the file at `path`.
88///
89/// Note: `path` doesn't have to exist.
90pub fn properties<P>(path: P) -> Result<Properties, Error>
91where
92    P: AsRef<Path>,
93{
94    properties_with_options(path, Options::default())
95}
96
97pub fn properties_with_options<P>(
98    path: P,
99    options: Options,
100) -> Result<Properties, Error>
101where
102    P: AsRef<Path>,
103{
104    let normalized_path = normalize_path(path.as_ref())?;
105    let mut properties = HashMap::new();
106
107    let ancestors: Vec<_> = path.as_ref().ancestors().skip(1).collect();
108
109    for dir in ancestors.iter().rev() {
110        parse_dir(dir, &normalized_path, &options, &mut properties)?;
111    }
112
113    process_properties(&mut properties, &options);
114
115    properties.retain(|key, _value| key != "unset");
116
117    Ok(properties)
118}
119
120/// Process and modify the properties to adhere to the specification at the
121/// version in `options`.
122fn process_properties(
123    properties: &mut HashMap<String, String>,
124    options: &Options,
125) {
126    // TODO: explain what's happening here.
127
128    const V0_9_0: Version = Version { major: 0, minor: 9, patch: 0 };
129
130    const INDENT_STYLE: &str = "indent_style";
131    const INDENT_SIZE: &str = "indent_size";
132    const TAB_WIDTH: &str = "tab_width";
133    const TAB: &str = "tab";
134
135    if options.version.cmp(&V0_9_0).is_ge() {
136        if properties.get(INDENT_STYLE).is_some_and(|v| v == TAB)
137            && !properties.contains_key(INDENT_SIZE)
138        {
139            properties.insert(INDENT_SIZE.to_owned(), TAB.to_owned());
140        }
141
142        if properties.get(INDENT_SIZE).is_some_and(|v| v == TAB)
143            && let Some(tab_width) = properties.get(TAB_WIDTH)
144        {
145            properties.insert(INDENT_SIZE.to_owned(), tab_width.to_owned());
146        }
147    }
148
149    if let Some(indent_size) = properties.get(INDENT_SIZE)
150        && !properties.contains_key(TAB_WIDTH)
151        && (options.version.cmp(&V0_9_0).is_lt() || indent_size != TAB)
152    {
153        properties.insert(TAB_WIDTH.to_owned(), indent_size.to_owned());
154    }
155}
156
157fn parse_dir(
158    ec_dir: &Path,
159    normalized_file_path: &str,
160    options: &Options,
161    properties: &mut HashMap<String, String>,
162) -> Result<(), Error> {
163    const COMMENT: &[char] = &['#', ';'];
164
165    let ec_file_path = ec_dir.join(options.file_name);
166    let ec_file = match File::open(ec_file_path) {
167        Ok(f) => f,
168        Err(e) if e.kind() == io::ErrorKind::NotFound => {
169            // The EditorConfig file doesn't have to exist at any of the dirs.
170            return Ok(());
171        }
172        Err(e) => return Err(Error::Io(e)),
173    };
174
175    let normalized_ec_dir = normalize_path(ec_dir)?;
176
177    let mut reader = BufReader::new(ec_file);
178
179    let mut line = String::new();
180
181    let mut section_matches_file = None;
182
183    while reader.read_line(&mut line).map_err(Error::Io)? != 0 {
184        let l = line
185            .strip_suffix('\n')
186            .unwrap_or(&line)
187            .strip_suffix('\r')
188            .unwrap_or(&line)
189            .trim();
190
191        if l.starts_with(COMMENT) {
192            // We ignore comment lines.
193        } else if let Some(is_match) =
194            parse_section(normalized_file_path, &normalized_ec_dir, l)?
195        {
196            section_matches_file = Some(is_match);
197        } else if section_matches_file.is_some_and(identity)
198            && let Some((key, value)) = parse_pair(l)
199        {
200            insert_pair(properties, key, value);
201        } else if section_matches_file.is_none()
202            && let Some((key, value)) = parse_pair(l)
203            && key.eq_ignore_ascii_case("root")
204            && value.eq_ignore_ascii_case("true")
205        {
206            // We walk from the root to the directory of the target file, so if
207            // an EditorConfig file is a root, it means that all the
208            // EditorConfig files "below" it should be discarded.
209            properties.clear();
210        }
211
212        line.clear();
213    }
214
215    Ok(())
216}
217
218fn insert_pair(
219    properties: &mut HashMap<String, String>,
220    key: &str,
221    value: &str,
222) {
223    const SPECIAL_KEYS: &[&str] = &[
224        "end_of_line",
225        "indent_style",
226        "indent_size",
227        "insert_final_newline",
228        "trim_trailing_whitespace",
229        "charset",
230    ];
231
232    let key = key.to_lowercase();
233    let value = if SPECIAL_KEYS.contains(&key.as_str()) {
234        value.to_lowercase()
235    } else {
236        value.to_owned()
237    };
238
239    properties.insert(key, value);
240}
241
242fn parse_section(
243    normalized_file_path: &str,
244    normalized_ec_dir: &str,
245    line: &str,
246) -> Result<Option<bool>, Error> {
247    let Some(pattern) =
248        line.strip_prefix('[').and_then(|l| l.strip_suffix(']'))
249    else {
250        return Ok(None);
251    };
252    let glob =
253        Glob::new(normalized_ec_dir, pattern).map_err(|_| Error::Parse)?;
254    Ok(Some(glob.is_match(normalized_file_path)))
255}
256
257fn parse_pair(line: &str) -> Option<(&str, &str)> {
258    let (key, value) = line.split_once('=')?;
259    let (key, value) = (key.trim(), value.trim());
260    (!key.is_empty()).then_some((key, value))
261}
262
263fn normalize_path(path: &Path) -> Result<String, Error> {
264    let path = path.to_str().ok_or(Error::InvalidPath)?;
265    Ok(if cfg!(windows) { path.replace('\\', "/") } else { path.to_owned() })
266}