1use std::path::Path;
14
15use ec4rs::Properties;
16use ec4rs::property::{
17 FinalNewline, IndentSize as EcIndentSize, IndentStyle as EcIndentStyle, MaxLineLen, TabWidth, TrimTrailingWs,
18};
19use ec4rs::rawvalue::RawValue;
20
21use super::source_tracking::{ConfigSource, SourcedConfig, SourcedValue};
22use crate::types::{IndentSize, LineLength};
23
24const HARD_TABS_RULE: &str = "MD010";
26const TRAILING_SPACES_RULE: &str = "MD009";
28const FINAL_NEWLINE_RULE: &str = "MD047";
30const UL_INDENT_RULE: &str = "MD007";
32const LINE_LENGTH_RULE: &str = "MD013";
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
40pub struct EditorConfigSettings {
41 pub line_length: Option<LineLength>,
44 pub indent: Option<IndentSize>,
46}
47
48impl EditorConfigSettings {
49 pub fn is_empty(&self) -> bool {
51 self.line_length.is_none() && self.indent.is_none()
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct EditorConfigWarning {
58 pub rule: Option<&'static str>,
63 pub message: String,
64}
65
66impl EditorConfigWarning {
67 fn unusable(message: String) -> Self {
68 Self { rule: None, message }
69 }
70
71 fn diverges(rule: &'static str, message: String) -> Self {
72 Self {
73 rule: Some(rule),
74 message,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Default)]
81pub struct EditorConfigResolution {
82 pub settings: EditorConfigSettings,
83 pub warnings: Vec<EditorConfigWarning>,
84 pub origin: Option<String>,
86}
87
88pub fn resolve(file_path: &Path) -> EditorConfigResolution {
99 let mut resolution = EditorConfigResolution::default();
100
101 let file_path = std::path::absolute(file_path).unwrap_or_else(|_| file_path.to_path_buf());
102
103 let props = match ec4rs::properties_of(&file_path) {
104 Ok(props) => props,
105 Err(e) => {
106 resolution.warnings.push(EditorConfigWarning::unusable(format!(
107 "Ignoring .editorconfig for {}: {e}",
108 file_path.display()
109 )));
110 return resolution;
111 }
112 };
113
114 read_max_line_length(&props, &mut resolution);
115 read_indent_size(&props, &mut resolution);
116 report_divergences(&props, &mut resolution);
117
118 resolution
119}
120
121pub fn apply<S>(sourced: &mut SourcedConfig<S>, settings: &EditorConfigSettings, origin: Option<&str>) {
127 let origin = || origin.map(str::to_string);
128
129 if let Some(line_length) = settings.line_length
134 && !rule_sets_its_own_line_length(sourced)
135 {
136 sourced
137 .global
138 .line_length
139 .merge_override(line_length, ConfigSource::EditorConfig, origin());
140 }
141
142 if let Some(indent) = settings.indent {
143 let rule = sourced.rules.entry(UL_INDENT_RULE.to_string()).or_default();
145 let value = toml::Value::Integer(i64::from(indent.get()));
146 rule.values
147 .entry("indent".to_string())
148 .or_insert_with(|| SourcedValue::new(value.clone(), ConfigSource::Default))
149 .merge_override(value, ConfigSource::EditorConfig, origin());
150 }
151}
152
153fn rule_sets_its_own_line_length<S>(sourced: &SourcedConfig<S>) -> bool {
156 sourced
157 .rules
158 .get(LINE_LENGTH_RULE)
159 .and_then(|rule| rule.values.get("line-length"))
160 .is_some_and(|value| !matches!(value.source, ConfigSource::Default | ConfigSource::EditorConfig))
161}
162
163fn read_max_line_length(props: &Properties, resolution: &mut EditorConfigResolution) {
164 let raw = props.get_raw::<MaxLineLen>();
165 match props.get::<MaxLineLen>() {
166 Ok(MaxLineLen::Off) => {
167 resolution.settings.line_length = Some(LineLength::new(0));
168 resolution.record_origin(raw);
169 }
170 Ok(MaxLineLen::Value(0)) => resolution.warnings.push(EditorConfigWarning::unusable(format!(
173 "{}: `max_line_length = 0` is not a usable limit; ignoring it. Use `off` for no limit.",
174 source_label(raw)
175 ))),
176 Ok(MaxLineLen::Value(limit)) => {
177 resolution.settings.line_length = Some(LineLength::new(limit));
178 resolution.record_origin(raw);
179 }
180 Err(raw) => report_unusable(raw, "max_line_length", resolution),
181 }
182}
183
184fn read_indent_size(props: &Properties, resolution: &mut EditorConfigResolution) {
185 let raw = props.get_raw::<EcIndentSize>();
186 let spaces = match props.get::<EcIndentSize>() {
187 Ok(EcIndentSize::Value(spaces)) => spaces,
188 Ok(EcIndentSize::UseTabWidth) => match props.get::<TabWidth>() {
191 Ok(TabWidth::Value(width)) => width,
192 Err(_) => {
193 resolution.warnings.push(EditorConfigWarning::unusable(format!(
194 "{}: `indent_size = tab` needs a `tab_width` to resolve to a number of spaces; ignoring it.",
195 source_label(raw)
196 )));
197 return;
198 }
199 },
200 Err(raw) => {
201 report_unusable(raw, "indent_size", resolution);
202 return;
203 }
204 };
205
206 match u8::try_from(spaces).ok().and_then(|s| IndentSize::new(s).ok()) {
207 Some(indent) => {
208 resolution.settings.indent = Some(indent);
209 resolution.record_origin(raw);
210 }
211 None => resolution.warnings.push(EditorConfigWarning::unusable(format!(
212 "{}: `indent_size = {spaces}` is outside the {}-{} spaces {UL_INDENT_RULE} accepts; ignoring it.",
213 source_label(raw),
214 IndentSize::MIN,
215 IndentSize::MAX
216 ))),
217 }
218}
219
220fn report_divergences(props: &Properties, resolution: &mut EditorConfigResolution) {
226 if let Ok(EcIndentStyle::Tabs) = props.get::<EcIndentStyle>() {
227 resolution.warnings.push(EditorConfigWarning::diverges(
228 HARD_TABS_RULE,
229 format!(
230 "{}: `indent_style = tab` is not applied; {HARD_TABS_RULE} flags hard tabs. \
231 Disable {HARD_TABS_RULE} in your rumdl config to allow them.",
232 source_label(props.get_raw::<EcIndentStyle>())
233 ),
234 ));
235 }
236
237 if let Ok(TrimTrailingWs::Value(false)) = props.get::<TrimTrailingWs>() {
238 resolution.warnings.push(EditorConfigWarning::diverges(
239 TRAILING_SPACES_RULE,
240 format!(
241 "{}: `trim_trailing_whitespace = false` is not applied; {TRAILING_SPACES_RULE} flags trailing \
242 whitespace. Disable {TRAILING_SPACES_RULE} in your rumdl config to allow it.",
243 source_label(props.get_raw::<TrimTrailingWs>())
244 ),
245 ));
246 }
247
248 if let Ok(FinalNewline::Value(false)) = props.get::<FinalNewline>() {
249 resolution.warnings.push(EditorConfigWarning::diverges(
250 FINAL_NEWLINE_RULE,
251 format!(
252 "{}: `insert_final_newline = false` is not applied; {FINAL_NEWLINE_RULE} requires a final newline. \
253 Disable {FINAL_NEWLINE_RULE} in your rumdl config to allow files without one.",
254 source_label(props.get_raw::<FinalNewline>())
255 ),
256 ));
257 }
258}
259
260fn report_unusable(raw: &RawValue, key: &str, resolution: &mut EditorConfigResolution) {
265 if let Ok(value) = raw.into_result() {
266 resolution.warnings.push(EditorConfigWarning::unusable(format!(
267 "{}: `{key} = {value}` is not a value rumdl can use; ignoring it.",
268 source_label(raw)
269 )));
270 }
271}
272
273impl EditorConfigResolution {
274 fn record_origin(&mut self, raw: &RawValue) {
277 if self.origin.is_none() {
278 self.origin = raw.source().map(|(path, _)| path.display().to_string());
279 }
280 }
281}
282
283fn source_label(raw: &RawValue) -> String {
290 match raw.source() {
291 Some((path, line)) => format!(
292 "{}:{line}",
293 super::validation::to_relative_display_path(&path.to_string_lossy())
294 ),
295 None => ".editorconfig".to_string(),
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::config::flavor::ConfigLoaded;
303 use std::fs;
304 use std::path::PathBuf;
305 use tempfile::TempDir;
306
307 fn fixture(contents: &str) -> (TempDir, PathBuf) {
312 let dir = TempDir::new().unwrap();
313 fs::write(dir.path().join(".editorconfig"), format!("root = true\n{contents}")).unwrap();
314 let doc = dir.path().join("doc.md");
315 fs::write(&doc, "# Title\n").unwrap();
316 (dir, doc)
317 }
318
319 fn messages(resolution: &EditorConfigResolution) -> String {
320 resolution
321 .warnings
322 .iter()
323 .map(|w| w.message.as_str())
324 .collect::<Vec<_>>()
325 .join("\n")
326 }
327
328 #[test]
329 fn maps_max_line_length_and_indent_size() {
330 let (_dir, doc) = fixture("[*.md]\nmax_line_length = 100\nindent_size = 4\n");
331 let resolution = resolve(&doc);
332
333 assert_eq!(resolution.settings.line_length, Some(LineLength::new(100)));
334 assert_eq!(resolution.settings.indent, Some(IndentSize::new(4).unwrap()));
335 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
336 assert!(
337 resolution.origin.is_some_and(|o| o.ends_with(".editorconfig")),
338 "the mapped value should be traced back to the file that set it"
339 );
340 }
341
342 #[test]
343 fn a_section_that_does_not_match_the_file_is_not_applied() {
344 let (_dir, doc) = fixture("[*.py]\nmax_line_length = 100\nindent_size = 4\n");
345 let resolution = resolve(&doc);
346
347 assert!(resolution.settings.is_empty());
348 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
349 }
350
351 #[test]
352 fn nearest_editorconfig_section_wins_over_a_broader_one() {
353 let (_dir, doc) = fixture("[*]\nmax_line_length = 80\n\n[*.md]\nmax_line_length = 120\n");
354 assert_eq!(resolve(&doc).settings.line_length, Some(LineLength::new(120)));
355 }
356
357 #[test]
358 fn max_line_length_off_means_no_limit() {
359 let (_dir, doc) = fixture("[*]\nmax_line_length = off\n");
360 let line_length = resolve(&doc).settings.line_length.expect("off should map");
361 assert!(line_length.is_unlimited());
362 }
363
364 #[test]
365 fn max_line_length_zero_is_reported_not_read_as_unlimited() {
366 let (_dir, doc) = fixture("[*]\nmax_line_length = 0\n");
367 let resolution = resolve(&doc);
368
369 assert_eq!(resolution.settings.line_length, None);
370 assert!(messages(&resolution).contains("max_line_length = 0"));
371 }
372
373 #[test]
374 fn indent_size_tab_resolves_through_tab_width() {
375 let (_dir, doc) = fixture("[*]\nindent_size = tab\ntab_width = 4\n");
376 let resolution = resolve(&doc);
377
378 assert_eq!(resolution.settings.indent, Some(IndentSize::new(4).unwrap()));
379 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
380 }
381
382 #[test]
383 fn indent_size_tab_without_tab_width_is_reported_not_guessed() {
384 let (_dir, doc) = fixture("[*]\nindent_size = tab\n");
385 let resolution = resolve(&doc);
386
387 assert_eq!(resolution.settings.indent, None);
388 assert!(messages(&resolution).contains("needs a `tab_width`"));
389 }
390
391 #[test]
392 fn indent_size_outside_the_supported_range_is_reported() {
393 for size in ["0", "12"] {
394 let (_dir, doc) = fixture(&format!("[*]\nindent_size = {size}\n"));
395 let resolution = resolve(&doc);
396
397 assert_eq!(resolution.settings.indent, None, "indent_size = {size} must not apply");
398 assert!(messages(&resolution).contains("outside the 1-8 spaces"));
399 }
400 }
401
402 #[test]
403 fn an_unparseable_value_is_reported_and_a_missing_one_is_not() {
404 let (_dir, doc) = fixture("[*]\nmax_line_length = wide\n");
405 let resolution = resolve(&doc);
406 assert_eq!(resolution.settings.line_length, None);
407 assert!(messages(&resolution).contains("max_line_length = wide"));
408
409 let (_dir, doc) = fixture("[*]\ncharset = utf-8\n");
410 let resolution = resolve(&doc);
411 assert!(resolution.settings.is_empty());
412 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
413 }
414
415 #[test]
416 fn the_unset_keyword_is_not_reported_as_a_bad_value() {
417 let (_dir, doc) = fixture("[*]\nmax_line_length = unset\nindent_size = unset\n");
418 let resolution = resolve(&doc);
419
420 assert!(resolution.settings.is_empty());
421 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
422 }
423
424 #[test]
425 fn only_the_values_rumdl_contradicts_are_reported() {
426 let (_dir, doc) = fixture(
427 "[*]\nindent_style = space\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nend_of_line = lf\n",
428 );
429 let resolution = resolve(&doc);
430 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
431
432 let (_dir, doc) = fixture(
433 "[*]\nindent_style = tab\ntrim_trailing_whitespace = false\ninsert_final_newline = false\nend_of_line = crlf\n",
434 );
435 let resolution = resolve(&doc);
436 let rules: Vec<_> = resolution.warnings.iter().map(|w| w.rule).collect();
437 assert_eq!(
438 rules,
439 vec![
440 Some(HARD_TABS_RULE),
441 Some(TRAILING_SPACES_RULE),
442 Some(FINAL_NEWLINE_RULE)
443 ],
444 "{}",
445 messages(&resolution)
446 );
447 }
448
449 #[test]
450 fn a_missing_editorconfig_resolves_to_nothing() {
451 let dir = TempDir::new().unwrap();
452 let doc = dir.path().join("doc.md");
453 fs::write(&doc, "# Title\n").unwrap();
454
455 let resolution = resolve(&doc);
458 assert!(resolution.warnings.is_empty(), "{}", messages(&resolution));
459 }
460
461 #[test]
462 fn apply_fills_in_defaults_but_never_overrides_a_rumdl_config() {
463 let settings = EditorConfigSettings {
464 line_length: Some(LineLength::new(120)),
465 indent: Some(IndentSize::new(4).unwrap()),
466 };
467
468 let mut sourced = SourcedConfig::<ConfigLoaded>::default();
469 apply(&mut sourced, &settings, Some(".editorconfig"));
470 assert_eq!(sourced.global.line_length.value.get(), 120);
471 assert_eq!(sourced.global.line_length.source, ConfigSource::EditorConfig);
472 assert_eq!(
473 sourced.rules[UL_INDENT_RULE].values["indent"].value,
474 toml::Value::Integer(4)
475 );
476
477 let mut sourced = SourcedConfig::<ConfigLoaded>::default();
478 sourced
479 .global
480 .line_length
481 .push_override(LineLength::new(90), ConfigSource::ProjectConfig, None);
482 sourced
483 .rules
484 .entry(UL_INDENT_RULE.to_string())
485 .or_default()
486 .values
487 .insert(
488 "indent".to_string(),
489 SourcedValue::new(toml::Value::Integer(3), ConfigSource::ProjectConfig),
490 );
491
492 apply(&mut sourced, &settings, Some(".editorconfig"));
493 assert_eq!(sourced.global.line_length.value.get(), 90);
494 assert_eq!(
495 sourced.rules[UL_INDENT_RULE].values["indent"].value,
496 toml::Value::Integer(3)
497 );
498 }
499
500 #[test]
501 fn apply_leaves_the_global_limit_alone_when_the_rule_carries_its_own() {
502 let settings = EditorConfigSettings {
503 line_length: Some(LineLength::new(120)),
504 indent: None,
505 };
506
507 let mut sourced = SourcedConfig::<ConfigLoaded>::default();
508 sourced
509 .rules
510 .entry(LINE_LENGTH_RULE.to_string())
511 .or_default()
512 .values
513 .insert(
514 "line-length".to_string(),
515 SourcedValue::new(toml::Value::Integer(80), ConfigSource::ProjectConfig),
516 );
517
518 apply(&mut sourced, &settings, Some(".editorconfig"));
519 assert_eq!(
520 sourced.global.line_length.source,
521 ConfigSource::Default,
522 "the global limit stands in for MD013's, so filling it in would override the rule"
523 );
524 }
525}