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        let lines: Vec<&str> = content.lines().collect();
120        let mut output = String::new();
121
122        for (i, warning) in warnings.iter().enumerate() {
123            if i > 0 {
124                output.push('\n');
125            }
126
127            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
128            let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
129
130            // Header line: rule name and message
131            if self.use_colors {
132                output.push_str(&format!(
133                    "{} {}{}",
134                    rule_name.red().bold(),
135                    warning.message,
136                    fix_indicator.green()
137                ));
138            } else {
139                output.push_str(&format!("{rule_name} {}{fix_indicator}", warning.message));
140            }
141            output.push('\n');
142
143            // Location line
144            if self.use_colors {
145                output.push_str(&format!(
146                    " {} {}:{}:{}\n",
147                    "-->".blue().bold(),
148                    file_path,
149                    warning.line,
150                    warning.column,
151                ));
152            } else {
153                output.push_str(&format!(" --> {}:{}:{}\n", file_path, warning.line, warning.column,));
154            }
155
156            // Source context with gutter, source line, and carets
157            self.render_source_context(&mut output, warning, &lines);
158        }
159
160        // Remove trailing newline
161        if output.ends_with('\n') {
162            output.pop();
163        }
164
165        output
166    }
167
168    fn use_colors(&self) -> bool {
169        self.use_colors
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::rule::{Fix, Severity};
177
178    fn make_warning(line: usize, column: usize, end_column: usize, rule: &str, message: &str) -> LintWarning {
179        LintWarning {
180            line,
181            column,
182            end_line: line,
183            end_column,
184            rule_name: Some(rule.to_string()),
185            message: message.to_string(),
186            severity: Severity::Warning,
187            fix: None,
188        }
189    }
190
191    #[test]
192    fn test_full_formatter_without_content_falls_back() {
193        let formatter = FullFormatter::without_colors();
194        let warnings = vec![make_warning(1, 1, 5, "MD001", "Heading increment")];
195        let output = formatter.format_warnings(&warnings, "test.md");
196        assert!(output.contains("test.md:1:1:"));
197        assert!(output.contains("MD001"));
198        assert!(output.contains("Heading increment"));
199    }
200
201    #[test]
202    fn test_full_formatter_with_content() {
203        let formatter = FullFormatter::without_colors();
204        let content = "# Hello\n\nThis is a test line that is long\n";
205        let warnings = vec![make_warning(3, 1, 33, "MD013", "Line length")];
206        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
207
208        assert!(output.contains("MD013 Line length"));
209        assert!(output.contains(" --> test.md:3:1"));
210        assert!(output.contains("This is a test line that is long"));
211        assert!(output.contains("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"));
212    }
213
214    #[test]
215    fn test_full_formatter_with_fix_indicator() {
216        let formatter = FullFormatter::without_colors();
217        let content = "# Hello\n";
218        let warnings = vec![LintWarning {
219            line: 1,
220            column: 1,
221            end_line: 1,
222            end_column: 8,
223            rule_name: Some("MD022".to_string()),
224            message: "Headings should be surrounded by blank lines".to_string(),
225            severity: Severity::Warning,
226            fix: Some(Fix::new(0..8, "\n# Hello\n".to_string())),
227        }];
228        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
229        assert!(output.contains("[*]"));
230    }
231
232    #[test]
233    fn test_full_formatter_multiple_warnings() {
234        let formatter = FullFormatter::without_colors();
235        let content = "# Hello\n\nSecond line\n\nThird line\n";
236        let warnings = vec![
237            make_warning(1, 1, 8, "MD001", "First issue"),
238            make_warning(3, 1, 12, "MD013", "Second issue"),
239        ];
240        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
241
242        assert!(output.contains("MD001 First issue"));
243        assert!(output.contains("MD013 Second issue"));
244        assert!(output.contains("# Hello"));
245        assert!(output.contains("Second line"));
246    }
247
248    #[test]
249    fn test_full_formatter_column_offset() {
250        let formatter = FullFormatter::without_colors();
251        let content = "Some text with issue here\n";
252        let warnings = vec![make_warning(1, 16, 21, "MD001", "Problem here")];
253        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
254
255        // 15 spaces padding (col 16 - 1), then 5 carets (21-16)
256        assert!(output.contains("               ^^^^^"));
257    }
258
259    #[test]
260    fn test_full_formatter_empty_warnings() {
261        let formatter = FullFormatter::without_colors();
262        let content = "# Hello\n";
263        let output = formatter.format_warnings_with_content(&[], "test.md", content);
264        assert!(output.is_empty());
265    }
266
267    #[test]
268    fn test_full_formatter_line_out_of_range() {
269        let formatter = FullFormatter::without_colors();
270        let content = "Only one line\n";
271        let warnings = vec![make_warning(5, 1, 5, "MD001", "Out of range")];
272        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
273
274        // Should still show header and location, just no source context
275        assert!(output.contains("MD001 Out of range"));
276        assert!(output.contains(" --> test.md:5:1"));
277    }
278
279    #[test]
280    fn test_full_formatter_no_rule_name() {
281        let formatter = FullFormatter::without_colors();
282        let content = "# Hello\n";
283        let warnings = vec![LintWarning {
284            line: 1,
285            column: 1,
286            end_line: 1,
287            end_column: 5,
288            rule_name: None,
289            message: "Generic warning".to_string(),
290            severity: Severity::Warning,
291            fix: None,
292        }];
293        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
294        assert!(output.contains("unknown Generic warning"));
295    }
296
297    #[test]
298    fn test_full_formatter_single_char_caret() {
299        let formatter = FullFormatter::without_colors();
300        let content = "Hello world\n";
301        // When end_column == column, should still show at least one caret
302        let warnings = vec![make_warning(1, 5, 5, "MD001", "Single char")];
303        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
304        assert!(output.contains("    ^"));
305    }
306
307    #[test]
308    fn test_full_formatter_gutter_width_for_large_line_numbers() {
309        let formatter = FullFormatter::without_colors();
310        let mut content = String::new();
311        for i in 1..=150 {
312            content.push_str(&format!("Line {i}\n"));
313        }
314        let warnings = vec![make_warning(142, 1, 9, "MD001", "At line 142")];
315        let output = formatter.format_warnings_with_content(&warnings, "test.md", &content);
316
317        // Gutter should be 3 chars wide for line 142
318        assert!(output.contains("142 | Line 142"));
319    }
320
321    #[test]
322    fn test_full_formatter_empty_content_falls_back() {
323        let formatter = FullFormatter::without_colors();
324        let warnings = vec![make_warning(1, 1, 5, "MD001", "Test")];
325        let output = formatter.format_warnings_with_content(&warnings, "test.md", "");
326        // Should fall back to format_warnings (text-style)
327        assert!(output.contains("test.md:1:1:"));
328    }
329}