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