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 {
227                range: 0..8,
228                replacement: "\n# Hello\n".to_string(),
229            }),
230        }];
231        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
232        assert!(output.contains("[*]"));
233    }
234
235    #[test]
236    fn test_full_formatter_multiple_warnings() {
237        let formatter = FullFormatter::without_colors();
238        let content = "# Hello\n\nSecond line\n\nThird line\n";
239        let warnings = vec![
240            make_warning(1, 1, 8, "MD001", "First issue"),
241            make_warning(3, 1, 12, "MD013", "Second issue"),
242        ];
243        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
244
245        assert!(output.contains("MD001 First issue"));
246        assert!(output.contains("MD013 Second issue"));
247        assert!(output.contains("# Hello"));
248        assert!(output.contains("Second line"));
249    }
250
251    #[test]
252    fn test_full_formatter_column_offset() {
253        let formatter = FullFormatter::without_colors();
254        let content = "Some text with issue here\n";
255        let warnings = vec![make_warning(1, 16, 21, "MD001", "Problem here")];
256        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
257
258        // 15 spaces padding (col 16 - 1), then 5 carets (21-16)
259        assert!(output.contains("               ^^^^^"));
260    }
261
262    #[test]
263    fn test_full_formatter_empty_warnings() {
264        let formatter = FullFormatter::without_colors();
265        let content = "# Hello\n";
266        let output = formatter.format_warnings_with_content(&[], "test.md", content);
267        assert!(output.is_empty());
268    }
269
270    #[test]
271    fn test_full_formatter_line_out_of_range() {
272        let formatter = FullFormatter::without_colors();
273        let content = "Only one line\n";
274        let warnings = vec![make_warning(5, 1, 5, "MD001", "Out of range")];
275        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
276
277        // Should still show header and location, just no source context
278        assert!(output.contains("MD001 Out of range"));
279        assert!(output.contains(" --> test.md:5:1"));
280    }
281
282    #[test]
283    fn test_full_formatter_no_rule_name() {
284        let formatter = FullFormatter::without_colors();
285        let content = "# Hello\n";
286        let warnings = vec![LintWarning {
287            line: 1,
288            column: 1,
289            end_line: 1,
290            end_column: 5,
291            rule_name: None,
292            message: "Generic warning".to_string(),
293            severity: Severity::Warning,
294            fix: None,
295        }];
296        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
297        assert!(output.contains("unknown Generic warning"));
298    }
299
300    #[test]
301    fn test_full_formatter_single_char_caret() {
302        let formatter = FullFormatter::without_colors();
303        let content = "Hello world\n";
304        // When end_column == column, should still show at least one caret
305        let warnings = vec![make_warning(1, 5, 5, "MD001", "Single char")];
306        let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
307        assert!(output.contains("    ^"));
308    }
309
310    #[test]
311    fn test_full_formatter_gutter_width_for_large_line_numbers() {
312        let formatter = FullFormatter::without_colors();
313        let mut content = String::new();
314        for i in 1..=150 {
315            content.push_str(&format!("Line {i}\n"));
316        }
317        let warnings = vec![make_warning(142, 1, 9, "MD001", "At line 142")];
318        let output = formatter.format_warnings_with_content(&warnings, "test.md", &content);
319
320        // Gutter should be 3 chars wide for line 142
321        assert!(output.contains("142 | Line 142"));
322    }
323
324    #[test]
325    fn test_full_formatter_empty_content_falls_back() {
326        let formatter = FullFormatter::without_colors();
327        let warnings = vec![make_warning(1, 1, 5, "MD001", "Test")];
328        let output = formatter.format_warnings_with_content(&warnings, "test.md", "");
329        // Should fall back to format_warnings (text-style)
330        assert!(output.contains("test.md:1:1:"));
331    }
332}