Skip to main content

mdlint/format/
default.rs

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