1mod 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
68pub 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 pub file_name: &'a str,
82 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
92pub type Properties = HashMap<String, String>;
95
96pub 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
129fn process_properties(
132 properties: &mut HashMap<String, String>,
133 options: &Options,
134) {
135 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 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 } 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 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}