rumdl_lib/output/formatters/
pylint.rs

1//! Pylint-compatible output formatter
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5
6/// Pylint-compatible formatter: file:line:column: CODE message
7pub struct PylintFormatter;
8
9impl Default for PylintFormatter {
10    fn default() -> Self {
11        Self
12    }
13}
14
15impl PylintFormatter {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21impl OutputFormatter for PylintFormatter {
22    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
23        let mut output = String::new();
24
25        for warning in warnings {
26            let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
27
28            // Convert MD prefix to CMD for pylint convention
29            // Pylint uses C for Convention, so CMD = Convention + MD rule
30            let pylint_code = if let Some(stripped) = rule_name.strip_prefix("MD") {
31                format!("CMD{stripped}")
32            } else {
33                format!("C{rule_name}")
34            };
35
36            // Pylint format: file:line:column: [C0000] message
37            let line = format!(
38                "{}:{}:{}: [{}] {}",
39                file_path, warning.line, warning.column, pylint_code, warning.message
40            );
41
42            output.push_str(&line);
43            output.push('\n');
44        }
45
46        // Remove trailing newline
47        if output.ends_with('\n') {
48            output.pop();
49        }
50
51        output
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::rule::{Fix, Severity};
59
60    #[test]
61    fn test_pylint_formatter_default() {
62        let _formatter = PylintFormatter;
63        // No fields to test, just ensure it constructs
64    }
65
66    #[test]
67    fn test_pylint_formatter_new() {
68        let _formatter = PylintFormatter::new();
69        // No fields to test, just ensure it constructs
70    }
71
72    #[test]
73    fn test_format_warnings_empty() {
74        let formatter = PylintFormatter::new();
75        let warnings = vec![];
76        let output = formatter.format_warnings(&warnings, "test.md");
77        assert_eq!(output, "");
78    }
79
80    #[test]
81    fn test_format_single_warning() {
82        let formatter = PylintFormatter::new();
83        let warnings = vec![LintWarning {
84            line: 10,
85            column: 5,
86            end_line: 10,
87            end_column: 15,
88            rule_name: Some("MD001".to_string()),
89            message: "Heading levels should only increment by one level at a time".to_string(),
90            severity: Severity::Warning,
91            fix: None,
92        }];
93
94        let output = formatter.format_warnings(&warnings, "README.md");
95        assert_eq!(
96            output,
97            "README.md:10:5: [CMD001] Heading levels should only increment by one level at a time"
98        );
99    }
100
101    #[test]
102    fn test_format_multiple_warnings() {
103        let formatter = PylintFormatter::new();
104        let warnings = vec![
105            LintWarning {
106                line: 5,
107                column: 1,
108                end_line: 5,
109                end_column: 10,
110                rule_name: Some("MD001".to_string()),
111                message: "First warning".to_string(),
112                severity: Severity::Warning,
113                fix: None,
114            },
115            LintWarning {
116                line: 10,
117                column: 3,
118                end_line: 10,
119                end_column: 20,
120                rule_name: Some("MD013".to_string()),
121                message: "Second warning".to_string(),
122                severity: Severity::Error,
123                fix: None,
124            },
125        ];
126
127        let output = formatter.format_warnings(&warnings, "test.md");
128        let expected = "test.md:5:1: [CMD001] First warning\ntest.md:10:3: [CMD013] Second warning";
129        assert_eq!(output, expected);
130    }
131
132    #[test]
133    fn test_format_warning_with_fix() {
134        let formatter = PylintFormatter::new();
135        let warnings = vec![LintWarning {
136            line: 15,
137            column: 1,
138            end_line: 15,
139            end_column: 10,
140            rule_name: Some("MD022".to_string()),
141            message: "Headings should be surrounded by blank lines".to_string(),
142            severity: Severity::Warning,
143            fix: Some(Fix {
144                range: 100..110,
145                replacement: "\n# Heading\n".to_string(),
146            }),
147        }];
148
149        let output = formatter.format_warnings(&warnings, "doc.md");
150        // Pylint format doesn't show fix indicator
151        assert_eq!(
152            output,
153            "doc.md:15:1: [CMD022] Headings should be surrounded by blank lines"
154        );
155    }
156
157    #[test]
158    fn test_format_warning_unknown_rule() {
159        let formatter = PylintFormatter::new();
160        let warnings = vec![LintWarning {
161            line: 1,
162            column: 1,
163            end_line: 1,
164            end_column: 5,
165            rule_name: None,
166            message: "Unknown rule warning".to_string(),
167            severity: Severity::Warning,
168            fix: None,
169        }];
170
171        let output = formatter.format_warnings(&warnings, "file.md");
172        assert_eq!(output, "file.md:1:1: [Cunknown] Unknown rule warning");
173    }
174
175    #[test]
176    fn test_format_warning_non_md_rule() {
177        let formatter = PylintFormatter::new();
178        let warnings = vec![LintWarning {
179            line: 1,
180            column: 1,
181            end_line: 1,
182            end_column: 5,
183            rule_name: Some("CUSTOM001".to_string()),
184            message: "Custom rule warning".to_string(),
185            severity: Severity::Warning,
186            fix: None,
187        }];
188
189        let output = formatter.format_warnings(&warnings, "file.md");
190        assert_eq!(output, "file.md:1:1: [CCUSTOM001] Custom rule warning");
191    }
192
193    #[test]
194    fn test_pylint_code_conversion() {
195        let formatter = PylintFormatter::new();
196
197        // Test various MD codes
198        let test_cases = vec![("MD001", "CMD001"), ("MD010", "CMD010"), ("MD999", "CMD999")];
199
200        for (md_code, expected_pylint) in test_cases {
201            let warnings = vec![LintWarning {
202                line: 1,
203                column: 1,
204                end_line: 1,
205                end_column: 1,
206                rule_name: Some(md_code.to_string()),
207                message: "Test".to_string(),
208                severity: Severity::Warning,
209                fix: None,
210            }];
211
212            let output = formatter.format_warnings(&warnings, "test.md");
213            assert!(output.contains(&format!("[{expected_pylint}]")));
214        }
215    }
216
217    #[test]
218    fn test_edge_cases() {
219        let formatter = PylintFormatter::new();
220
221        // Test large line/column numbers
222        let warnings = vec![LintWarning {
223            line: 99999,
224            column: 12345,
225            end_line: 100000,
226            end_column: 12350,
227            rule_name: Some("MD999".to_string()),
228            message: "Edge case warning".to_string(),
229            severity: Severity::Error,
230            fix: None,
231        }];
232
233        let output = formatter.format_warnings(&warnings, "large.md");
234        assert_eq!(output, "large.md:99999:12345: [CMD999] Edge case warning");
235    }
236
237    #[test]
238    fn test_special_characters_in_message() {
239        let formatter = PylintFormatter::new();
240        let warnings = vec![LintWarning {
241            line: 1,
242            column: 1,
243            end_line: 1,
244            end_column: 5,
245            rule_name: Some("MD001".to_string()),
246            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
247            severity: Severity::Warning,
248            fix: None,
249        }];
250
251        let output = formatter.format_warnings(&warnings, "test.md");
252        assert_eq!(
253            output,
254            "test.md:1:1: [CMD001] Warning with \"quotes\" and 'apostrophes' and \n newline"
255        );
256    }
257
258    #[test]
259    fn test_special_characters_in_file_path() {
260        let formatter = PylintFormatter::new();
261        let warnings = vec![LintWarning {
262            line: 1,
263            column: 1,
264            end_line: 1,
265            end_column: 5,
266            rule_name: Some("MD001".to_string()),
267            message: "Test".to_string(),
268            severity: Severity::Warning,
269            fix: None,
270        }];
271
272        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
273        assert_eq!(output, "path/with spaces/and-dashes.md:1:1: [CMD001] Test");
274    }
275
276    #[test]
277    fn test_severity_ignored() {
278        let formatter = PylintFormatter::new();
279
280        // Test that severity doesn't affect output (pylint format doesn't show severity)
281        let warnings = vec![
282            LintWarning {
283                line: 1,
284                column: 1,
285                end_line: 1,
286                end_column: 5,
287                rule_name: Some("MD001".to_string()),
288                message: "Warning severity".to_string(),
289                severity: Severity::Warning,
290                fix: None,
291            },
292            LintWarning {
293                line: 2,
294                column: 1,
295                end_line: 2,
296                end_column: 5,
297                rule_name: Some("MD002".to_string()),
298                message: "Error severity".to_string(),
299                severity: Severity::Error,
300                fix: None,
301            },
302        ];
303
304        let output = formatter.format_warnings(&warnings, "test.md");
305        let lines: Vec<&str> = output.lines().collect();
306
307        // Both should have same format regardless of severity
308        assert!(lines[0].starts_with("test.md:1:1: [CMD001]"));
309        assert!(lines[1].starts_with("test.md:2:1: [CMD002]"));
310    }
311}