Skip to main content

rumdl_lib/config/
editorconfig.rs

1//! `.editorconfig` support, opt-in via `[global] editorconfig = true`.
2//!
3//! Properties are resolved per file, so section globs and nested
4//! `.editorconfig` files apply exactly as written, and are layered in at
5//! [`ConfigSource::EditorConfig`]: they fill in settings no rumdl config
6//! mentions and lose to any that it does.
7//!
8//! Only properties with an unambiguous rumdl equivalent are mapped. The rest
9//! are read solely to report where rumdl's behavior contradicts what the
10//! `.editorconfig` asks for; every such warning names the rule responsible, so
11//! the caller can drop it when that rule is not enabled.
12
13use 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
24/// Decides whether hard tabs are allowed.
25const HARD_TABS_RULE: &str = "MD010";
26/// Decides whether trailing whitespace is allowed.
27const TRAILING_SPACES_RULE: &str = "MD009";
28/// Requires a single trailing newline.
29const FINAL_NEWLINE_RULE: &str = "MD047";
30/// Owns the `indent` option that `indent_size` maps onto.
31const UL_INDENT_RULE: &str = "MD007";
32/// Enforces the line length, and is the only rule the global one is read for.
33const LINE_LENGTH_RULE: &str = "MD013";
34
35/// The `.editorconfig` properties rumdl maps onto its own settings.
36///
37/// Ordered so it can key a map of file groups: two files resolving to the same
38/// settings share one effective config.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
40pub struct EditorConfigSettings {
41    /// `max_line_length`, mapped onto `[global] line-length`. `off` becomes
42    /// [`LineLength::new(0)`], which rumdl reads as no limit.
43    pub line_length: Option<LineLength>,
44    /// `indent_size` in spaces, mapped onto MD007's `indent`.
45    pub indent: Option<IndentSize>,
46}
47
48impl EditorConfigSettings {
49    /// Whether any property mapped onto a rumdl setting.
50    pub fn is_empty(&self) -> bool {
51        self.line_length.is_none() && self.indent.is_none()
52    }
53}
54
55/// A `.editorconfig` property rumdl read but does not act on.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct EditorConfigWarning {
58    /// The rule whose behavior contradicts the property, when the divergence is
59    /// rule-specific. Such a message is only true while that rule is enabled.
60    /// `None` means the property itself is unusable, which is worth reporting
61    /// whatever rules are active.
62    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/// What the `.editorconfig` files applying to one file resolve to.
80#[derive(Debug, Clone, Default)]
81pub struct EditorConfigResolution {
82    pub settings: EditorConfigSettings,
83    pub warnings: Vec<EditorConfigWarning>,
84    /// The `.editorconfig` file that supplied a mapped value.
85    pub origin: Option<String>,
86}
87
88/// Resolve the `.editorconfig` properties that apply to `file_path`.
89///
90/// The path is anchored to the working directory first: lookup walks the file's
91/// ancestors, and a relative path has none. It is not canonicalized, so a file
92/// that does not exist yet (an unsaved editor buffer named on `--stdin-filename`)
93/// still resolves against the directory it belongs to.
94///
95/// Never fails: a `.editorconfig` that cannot be parsed yields no settings and
96/// one warning, so a broken file downgrades to "no editorconfig" rather than
97/// aborting the lint.
98pub 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
121/// Layer resolved settings into a config at [`ConfigSource::EditorConfig`].
122///
123/// Precedence does the work: a value still at [`ConfigSource::Default`] takes
124/// the `.editorconfig` value, and anything a rumdl config or the CLI set
125/// outranks it.
126pub fn apply<S>(sourced: &mut SourcedConfig<S>, settings: &EditorConfigSettings, origin: Option<&str>) {
127    let origin = || origin.map(str::to_string);
128
129    // The global line length is only ever read as MD013's limit, and only while
130    // MD013 has no `line-length` of its own. Filling it in would therefore
131    // replace a limit the rumdl config set on the rule. Precedence cannot catch
132    // that: the two settings are reconciled once the sources are gone.
133    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        // Rule keys are canonical uppercase once merged into a `SourcedConfig`.
144        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
153/// Whether a rumdl config or the CLI gave MD013 a line length of its own, which
154/// the global setting merely stands in for.
155fn 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        // A limit of zero is not a limit, and reading it as "unlimited" would
171        // turn a meaningless value into a confident setting.
172        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        // `indent_size = tab` defers to `tab_width`. Guessing a width when none
189        // is given would invent an indent the project never stated.
190        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
220/// Report the properties rumdl reads but will not follow.
221///
222/// Only the values that actually contradict rumdl are reported: asking for
223/// spaces, trimmed trailing whitespace or a final newline is what rumdl already
224/// enforces, so those stay silent.
225fn 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
260/// Report a value that was set but could not be parsed.
261///
262/// A property that was never set, and one written as the literal `unset`, both
263/// mean "no value to map" rather than a mistake, so neither is reported.
264fn 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    /// Record which `.editorconfig` supplied a mapped value. The first one wins,
275    /// which for a single mapped value is the file that set it.
276    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
283/// A `file:line` label for a property, for messages that must say where a value
284/// came from. Falls back to the bare filename when source tracking has nothing.
285///
286/// The path is shown relative to the working directory, as config warnings from
287/// rumdl's own files are; `.editorconfig` paths are absolute because the walk
288/// that found them is.
289fn 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    /// Write a `.editorconfig` and a `doc.md` beside it, returning the doc path.
308    ///
309    /// Every fixture is rooted so the walk stops inside the temp directory and
310    /// no `.editorconfig` from the machine it runs on can leak in.
311    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        // A `.editorconfig` above the temp directory could still apply, so this
456        // asserts only that the absence of one here is not itself a problem.
457        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}