1mod 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
59pub 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 pub file_name: &'a str,
73 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
83pub type Properties = HashMap<String, String>;
86
87pub 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
120fn process_properties(
123 properties: &mut HashMap<String, String>,
124 options: &Options,
125) {
126 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 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 } 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 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}