Skip to main content

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::new(100..110, "\n# Heading\n".to_string())),
144        }];
145
146        let output = formatter.format_warnings(&warnings, "doc.md");
147        // Pylint format doesn't show fix indicator
148        assert_eq!(
149            output,
150            "doc.md:15:1: [CMD022] Headings should be surrounded by blank lines"
151        );
152    }
153
154    #[test]
155    fn test_format_warning_unknown_rule() {
156        let formatter = PylintFormatter::new();
157        let warnings = vec![LintWarning {
158            line: 1,
159            column: 1,
160            end_line: 1,
161            end_column: 5,
162            rule_name: None,
163            message: "Unknown rule warning".to_string(),
164            severity: Severity::Warning,
165            fix: None,
166        }];
167
168        let output = formatter.format_warnings(&warnings, "file.md");
169        assert_eq!(output, "file.md:1:1: [Cunknown] Unknown rule warning");
170    }
171
172    #[test]
173    fn test_format_warning_non_md_rule() {
174        let formatter = PylintFormatter::new();
175        let warnings = vec![LintWarning {
176            line: 1,
177            column: 1,
178            end_line: 1,
179            end_column: 5,
180            rule_name: Some("CUSTOM001".to_string()),
181            message: "Custom rule warning".to_string(),
182            severity: Severity::Warning,
183            fix: None,
184        }];
185
186        let output = formatter.format_warnings(&warnings, "file.md");
187        assert_eq!(output, "file.md:1:1: [CCUSTOM001] Custom rule warning");
188    }
189
190    #[test]
191    fn test_pylint_code_conversion() {
192        let formatter = PylintFormatter::new();
193
194        // Test various MD codes
195        let test_cases = vec![("MD001", "CMD001"), ("MD010", "CMD010"), ("MD999", "CMD999")];
196
197        for (md_code, expected_pylint) in test_cases {
198            let warnings = vec![LintWarning {
199                line: 1,
200                column: 1,
201                end_line: 1,
202                end_column: 1,
203                rule_name: Some(md_code.to_string()),
204                message: "Test".to_string(),
205                severity: Severity::Warning,
206                fix: None,
207            }];
208
209            let output = formatter.format_warnings(&warnings, "test.md");
210            assert!(output.contains(&format!("[{expected_pylint}]")));
211        }
212    }
213
214    #[test]
215    fn test_edge_cases() {
216        let formatter = PylintFormatter::new();
217
218        // Test large line/column numbers
219        let warnings = vec![LintWarning {
220            line: 99999,
221            column: 12345,
222            end_line: 100000,
223            end_column: 12350,
224            rule_name: Some("MD999".to_string()),
225            message: "Edge case warning".to_string(),
226            severity: Severity::Error,
227            fix: None,
228        }];
229
230        let output = formatter.format_warnings(&warnings, "large.md");
231        assert_eq!(output, "large.md:99999:12345: [CMD999] Edge case warning");
232    }
233
234    #[test]
235    fn test_special_characters_in_message() {
236        let formatter = PylintFormatter::new();
237        let warnings = vec![LintWarning {
238            line: 1,
239            column: 1,
240            end_line: 1,
241            end_column: 5,
242            rule_name: Some("MD001".to_string()),
243            message: "Warning with \"quotes\" and 'apostrophes' and \n newline".to_string(),
244            severity: Severity::Warning,
245            fix: None,
246        }];
247
248        let output = formatter.format_warnings(&warnings, "test.md");
249        assert_eq!(
250            output,
251            "test.md:1:1: [CMD001] Warning with \"quotes\" and 'apostrophes' and \n newline"
252        );
253    }
254
255    #[test]
256    fn test_special_characters_in_file_path() {
257        let formatter = PylintFormatter::new();
258        let warnings = vec![LintWarning {
259            line: 1,
260            column: 1,
261            end_line: 1,
262            end_column: 5,
263            rule_name: Some("MD001".to_string()),
264            message: "Test".to_string(),
265            severity: Severity::Warning,
266            fix: None,
267        }];
268
269        let output = formatter.format_warnings(&warnings, "path/with spaces/and-dashes.md");
270        assert_eq!(output, "path/with spaces/and-dashes.md:1:1: [CMD001] Test");
271    }
272
273    #[test]
274    fn test_severity_ignored() {
275        let formatter = PylintFormatter::new();
276
277        // Test that severity doesn't affect output (pylint format doesn't show severity)
278        let warnings = vec![
279            LintWarning {
280                line: 1,
281                column: 1,
282                end_line: 1,
283                end_column: 5,
284                rule_name: Some("MD001".to_string()),
285                message: "Warning severity".to_string(),
286                severity: Severity::Warning,
287                fix: None,
288            },
289            LintWarning {
290                line: 2,
291                column: 1,
292                end_line: 2,
293                end_column: 5,
294                rule_name: Some("MD002".to_string()),
295                message: "Error severity".to_string(),
296                severity: Severity::Error,
297                fix: None,
298            },
299        ];
300
301        let output = formatter.format_warnings(&warnings, "test.md");
302        let lines: Vec<&str> = output.lines().collect();
303
304        // Both should have same format regardless of severity
305        assert!(lines[0].starts_with("test.md:1:1: [CMD001]"));
306        assert!(lines[1].starts_with("test.md:2:1: [CMD002]"));
307    }
308}