Skip to main content

rumdl_lib/output/formatters/
full.rs

1//! Full output formatter with source line display (ruff-style)
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use colored::*;
6
7/// Full formatter that shows source lines with carets (ruff-style)
8pub struct FullFormatter {
9    use_colors: bool,
10}
11
12impl Default for FullFormatter {
13    fn default() -> Self {
14        Self { use_colors: true }
15    }
16}
17
18impl FullFormatter {
19    pub fn new() -> Self {
20        Self::default()
21    }
22
23    pub fn without_colors() -> Self {
24        Self { use_colors: false }
25    }
26
27    /// Render the source context block: gutter, source line, and caret underline.
28    fn render_source_context(&self, output: &mut String, warning: &LintWarning, lines: &[&str]) {
29        let line_idx = warning.line.saturating_sub(1);
30        if line_idx >= lines.len() {
31            return;
32        }
33
34        let source_line = lines[line_idx];
35        let line_num = warning.line;
36        let gutter_width = line_num.to_string().len().max(2);
37        let empty_gutter = " ".repeat(gutter_width);
38
39        // Empty gutter line
40        if self.use_colors {
41            output.push_str(&format!("{empty_gutter} {}\n", "|".blue().bold()));
42        } else {
43            output.push_str(&format!("{empty_gutter} |\n"));
44        }
45
46        // Source line with line number
47        if self.use_colors {
48            output.push_str(&format!(
49                "{:>width$} {} {}\n",
50                line_num.to_string().blue().bold(),
51                "|".blue().bold(),
52                source_line,
53                width = gutter_width,
54            ));
55        } else {
56            output.push_str(&format!("{line_num:>gutter_width$} | {source_line}\n"));
57        }
58
59        // Caret underline
60        let col = warning.column.saturating_sub(1);
61        let end_col = if warning.end_column > warning.column {
62            warning.end_column.saturating_sub(1)
63        } else {
64            col + 1
65        };
66        let caret_len = end_col.saturating_sub(col).max(1);
67        let padding = " ".repeat(col);
68        let carets = "^".repeat(caret_len);
69
70        if self.use_colors {
71            output.push_str(&format!(
72                "{empty_gutter} {} {padding}{}\n",
73                "|".blue().bold(),
74                carets.yellow().bold(),
75            ));
76        } else {
77            output.push_str(&format!("{empty_gutter} | {padding}{carets}\n"));
78        }
79
80        // Closing empty gutter line
81        if self.use_colors {
82            output.push_str(&format!("{empty_gutter} {}\n", "|".blue().bold()));
83        } else {
84            output.push_str(&format!("{empty_gutter} |\n"));
85        }
86    }
87}
88
89impl OutputFormatter for FullFormatter {
90    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
91        // Without content, fall back to text-style output
92        let mut output = String::new();
93
94        for warning in warnings {
95            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
96            let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
97
98            let line = format!(
99                "{}:{}:{}: [{}] {}{}",
100                file_path, warning.line, warning.column, rule_name, warning.message, fix_indicator,
101            );
102
103            output.push_str(&line);
104            output.push('\n');
105        }
106
107        if output.ends_with('\n') {
108            output.pop();
109        }
110
111        output
112    }
113
114    fn format_warnings_with_content(&self, warnings: &[LintWarning], file_path: &str, content: &str) -> String {
115        if content.is_empty() {
116            return self.format_warnings(warnings, file_path);
117        }
118
119        // Warning lines count from the text after the byte order mark.
120        let lines: Vec<&str> = crate::utils::bom::strip_bom(content).lines().collect();
121        let mut output = String::new();
122
123        for (i, warning) in warnings.iter().enumerate() {
124            if i > 0 {
125                output.push('\n');
126            }
127
128            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
129            let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
130
131            // Header line: rule name and message
132            if self.use_colors {
133                output.push_str(&format!(
134                    "{} {}{}",
135                    rule_name.red().bold(),
136                    warning.message,
137                    fix_indicator.green()
138                ));
139            } else {
140                output.push_str(&format!("{rule_name} {}{fix_indicator}", warning.message));
141            }
142            output.push('\n');
143
144            // Location line
145            if self.use_colors {
146                output.push_str(&format!(
147                    " {} {}:{}:{}\n",
148                    "-->".blue().bold(),
149                    file_path,
150                    warning.line,
151                    warning.column,
152                ));
153            } else {
154                output.push_str(&format!(" --> {}:{}:{}\n", file_path, warning.line, warning.column));
155            }
156
157            // Source context with gutter, source line, and carets
158            self.render_source_context(&mut output, warning, &lines);
159        }
160
161        // Remove trailing newline
162        if output.ends_with('\n') {
163            output.pop();
164        }
165
166        output
167    }
168
169    fn use_colors(&self) -> bool {
170        self.use_colors
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::rule::{Fix, Severity};
178
179    fn make_warning(line: usize, column: usize, end_column: usize, rule: &str, message: &str) -> LintWarning {
180        LintWarning {
181            line,
182            column,
183            end_line: line,
184            end_column,
185            rule_name: Some(rule.to_string()),
186            message: message.to_string(),
187            severity: Severity::Warning,
188            fix: None,
189        }
190    }
191
192    #[test]
193    fn test_full_formatter_without_content_falls_back() {
194        let formatter = FullFormatter::without_colors();
195        let warnings = vec![make_warning(1, 1, 5, "MD001", "Heading increment")];
196        let output = formatter.format_warnings(&warnings, "test.md");
197        assert!(output.contains("test.md:1:1:"));
198        assert!(output.contains("MD001"));
199        assert!(output.contains("Heading increment"));
200    }
201
202    #[test]
203    fn test_full_formatter_with_content() {
204        let formatter = FullFormatter::without_colors();
205        let content = "# Hello\n\nThis is a test line that is long\n";
206        let warnings = vec![make_warning(3, 1, 33, "MD013", "Line length")];
207        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
208
209        assert!(output.contains("MD013 Line length"));
210        assert!(output.contains(" --> test.md:3:1"));
211        assert!(output.contains("This is a test line that is long"));
212        assert!(output.contains("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"));
213    }
214
215    #[test]
216    fn test_full_formatter_with_fix_indicator() {
217        let formatter = FullFormatter::without_colors();
218        let content = "# Hello\n";
219        let warnings = vec![LintWarning {
220            line: 1,
221            column: 1,
222            end_line: 1,
223            end_column: 8,
224            rule_name: Some("MD022".to_string()),
225            message: "Headings should be surrounded by blank lines".to_string(),
226            severity: Severity::Warning,
227            fix: Some(Fix::new(0..8, "\n# Hello\n".to_string())),
228        }];
229        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
230        assert!(output.contains("[*]"));
231    }
232
233    #[test]
234    fn test_full_formatter_multiple_warnings() {
235        let formatter = FullFormatter::without_colors();
236        let content = "# Hello\n\nSecond line\n\nThird line\n";
237        let warnings = vec![
238            make_warning(1, 1, 8, "MD001", "First issue"),
239            make_warning(3, 1, 12, "MD013", "Second issue"),
240        ];
241        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
242
243        assert!(output.contains("MD001 First issue"));
244        assert!(output.contains("MD013 Second issue"));
245        assert!(output.contains("# Hello"));
246        assert!(output.contains("Second line"));
247    }
248
249    #[test]
250    fn test_full_formatter_column_offset() {
251        let formatter = FullFormatter::without_colors();
252        let content = "Some text with issue here\n";
253        let warnings = vec![make_warning(1, 16, 21, "MD001", "Problem here")];
254        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
255
256        // 15 spaces padding (col 16 - 1), then 5 carets (21-16)
257        assert!(output.contains("               ^^^^^"));
258    }
259
260    #[test]
261    fn test_full_formatter_empty_warnings() {
262        let formatter = FullFormatter::without_colors();
263        let content = "# Hello\n";
264        let output = formatter.format_warnings_with_content(&[], "test.md", content);
265        assert!(output.is_empty());
266    }
267
268    #[test]
269    fn test_full_formatter_line_out_of_range() {
270        let formatter = FullFormatter::without_colors();
271        let content = "Only one line\n";
272        let warnings = vec![make_warning(5, 1, 5, "MD001", "Out of range")];
273        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
274
275        // Should still show header and location, just no source context
276        assert!(output.contains("MD001 Out of range"));
277        assert!(output.contains(" --> test.md:5:1"));
278    }
279
280    #[test]
281    fn test_full_formatter_no_rule_name() {
282        let formatter = FullFormatter::without_colors();
283        let content = "# Hello\n";
284        let warnings = vec![LintWarning {
285            line: 1,
286            column: 1,
287            end_line: 1,
288            end_column: 5,
289            rule_name: None,
290            message: "Generic warning".to_string(),
291            severity: Severity::Warning,
292            fix: None,
293        }];
294        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
295        assert!(output.contains("unknown Generic warning"));
296    }
297
298    #[test]
299    fn test_full_formatter_single_char_caret() {
300        let formatter = FullFormatter::without_colors();
301        let content = "Hello world\n";
302        // When end_column == column, should still show at least one caret
303        let warnings = vec![make_warning(1, 5, 5, "MD001", "Single char")];
304        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
305        assert!(output.contains("    ^"));
306    }
307
308    #[test]
309    fn test_full_formatter_gutter_width_for_large_line_numbers() {
310        let formatter = FullFormatter::without_colors();
311        let mut content = String::new();
312        for i in 1..=150 {
313            content.push_str(&format!("Line {i}\n"));
314        }
315        let warnings = vec![make_warning(142, 1, 9, "MD001", "At line 142")];
316        let output = formatter.format_warnings_with_content(&warnings, "test.md", &content);
317
318        // Gutter should be 3 chars wide for line 142
319        assert!(output.contains("142 | Line 142"));
320    }
321
322    #[test]
323    fn test_full_formatter_empty_content_falls_back() {
324        let formatter = FullFormatter::without_colors();
325        let warnings = vec![make_warning(1, 1, 5, "MD001", "Test")];
326        let output = formatter.format_warnings_with_content(&warnings, "test.md", "");
327        // Should fall back to format_warnings (text-style)
328        assert!(output.contains("test.md:1:1:"));
329    }
330}