Skip to main content

mdlint/format/
default.rs

1use crate::format::Formatter;
2use crate::lint::LintResult;
3
4pub struct DefaultFormatter {
5    use_color: bool,
6    /// Show the offending source line and column indicator under each violation.
7    show_context: bool,
8}
9
10impl DefaultFormatter {
11    pub fn new(use_color: bool) -> Self {
12        Self {
13            use_color,
14            show_context: true,
15        }
16    }
17
18    pub fn without_context(use_color: bool) -> Self {
19        Self {
20            use_color,
21            show_context: false,
22        }
23    }
24
25    fn colorize(&self, text: &str, color_code: &str) -> String {
26        if self.use_color {
27            format!("\x1b[{}m{}\x1b[0m", color_code, text)
28        } else {
29            text.to_string()
30        }
31    }
32
33    fn red(&self, text: &str) -> String {
34        self.colorize(text, "31")
35    }
36
37    fn yellow(&self, text: &str) -> String {
38        self.colorize(text, "33")
39    }
40
41    fn gray(&self, text: &str) -> String {
42        self.colorize(text, "90")
43    }
44}
45
46impl Formatter for DefaultFormatter {
47    fn format(&self, result: &LintResult) -> String {
48        let mut output = String::new();
49
50        // Output violations by file
51        for file_result in &result.file_results {
52            if file_result.violations.is_empty() {
53                continue;
54            }
55
56            // File path header
57            let path_display = file_result.path.display();
58            output.push_str(&format!("{}\n", self.yellow(&path_display.to_string())));
59
60            // Each violation
61            for violation in &file_result.violations {
62                let location = if let Some(col) = violation.column {
63                    format!("{}:{}", violation.line, col)
64                } else {
65                    format!("{}", violation.line)
66                };
67
68                output.push_str(&format!(
69                    "  {}: {} {}\n",
70                    self.gray(&location),
71                    self.red(&violation.rule),
72                    violation.message
73                ));
74
75                // Source snippet
76                if self.show_context {
77                    let line_idx = violation.line.saturating_sub(1);
78                    if let Some(src) = file_result.source_lines.get(line_idx) {
79                        let src_trimmed = src.trim_end();
80                        output.push_str(&format!("       | {}\n", src_trimmed));
81                        if let Some(col) = violation.column {
82                            // Point at the column with a caret (col is 1-indexed)
83                            let spaces = " ".repeat(col.saturating_sub(1));
84                            output.push_str(&format!("       | {}{}\n", spaces, self.red("^")));
85                        }
86                    }
87                }
88            }
89
90            output.push('\n');
91        }
92
93        // Summary line
94        let files_with_errors = result.file_results.len();
95        let total = result.total_files_checked;
96        if result.total_errors == 0 {
97            let msg = format!("Checked {} file(s), no errors found.", total);
98            output.push_str(&format!("{}\n", self.gray(&msg)));
99        } else {
100            let summary = format!(
101                "Found {} error(s) in {} file(s) ({} checked)",
102                result.total_errors, files_with_errors, total
103            );
104            output.push_str(&format!("{}\n", self.red(&summary)));
105        }
106
107        output
108    }
109
110    fn supports_color(&self) -> bool {
111        self.use_color
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::types::Violation;
119    use std::path::PathBuf;
120
121    #[test]
122    fn test_no_errors() {
123        let formatter = DefaultFormatter::new(false);
124        let result = LintResult::new();
125        let output = formatter.format(&result);
126
127        assert!(output.contains("no errors found"));
128    }
129
130    fn make_violation(line: usize, col: Option<usize>, rule: &str, msg: &str) -> Violation {
131        Violation {
132            line,
133            column: col,
134            rule: rule.to_string(),
135            message: msg.to_string(),
136            fix: None,
137        }
138    }
139
140    #[test]
141    fn test_single_violation() {
142        let formatter = DefaultFormatter::without_context(false);
143        let mut result = LintResult::new();
144        result.add_file_result(
145            PathBuf::from("test.md"),
146            vec![make_violation(
147                5,
148                Some(10),
149                "MD001",
150                "Heading levels should increment by one",
151            )],
152            vec![],
153        );
154        let output = formatter.format(&result);
155        assert!(output.contains("test.md"));
156        assert!(output.contains("5:10"));
157        assert!(output.contains("MD001"));
158        assert!(output.contains("Heading levels"));
159        assert!(output.contains("Found 1 error(s)"));
160    }
161
162    #[test]
163    fn test_multiple_violations() {
164        let formatter = DefaultFormatter::without_context(false);
165        let mut result = LintResult::new();
166        result.add_file_result(
167            PathBuf::from("file1.md"),
168            vec![
169                make_violation(1, Some(1), "MD001", "First error"),
170                make_violation(10, None, "MD002", "Second error"),
171            ],
172            vec![],
173        );
174        result.add_file_result(
175            PathBuf::from("file2.md"),
176            vec![make_violation(3, Some(5), "MD003", "Third error")],
177            vec![],
178        );
179        let output = formatter.format(&result);
180        assert!(output.contains("file1.md"));
181        assert!(output.contains("file2.md"));
182        assert!(output.contains("Found 3 error(s) in 2 file(s)"));
183    }
184
185    #[test]
186    fn test_with_color() {
187        let formatter = DefaultFormatter::new(true);
188        let mut result = LintResult::new();
189        result.add_file_result(
190            PathBuf::from("test.md"),
191            vec![make_violation(5, Some(10), "MD001", "Test error")],
192            vec![],
193        );
194        let output = formatter.format(&result);
195        assert!(output.contains("\x1b["));
196    }
197
198    #[test]
199    fn test_source_snippet_shown() {
200        let formatter = DefaultFormatter::new(false);
201        let mut result = LintResult::new();
202        let source_lines = vec![
203            "# Good Heading".to_string(),
204            "#Bad heading".to_string(),
205            "More text".to_string(),
206        ];
207        result.add_file_result(
208            PathBuf::from("test.md"),
209            vec![make_violation(2, Some(1), "MD018", "No space after hash")],
210            source_lines,
211        );
212        let output = formatter.format(&result);
213        assert!(
214            output.contains("#Bad heading"),
215            "snippet should appear in output"
216        );
217        assert!(output.contains('^'), "caret should appear under the column");
218    }
219}