Skip to main content

rumdl_lib/output/formatters/
json.rs

1//! JSON output formatter
2
3use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::{Value, json};
6
7/// JSON formatter for machine-readable output
8#[derive(Default)]
9pub struct JsonFormatter {
10    collect_all: bool,
11}
12
13impl JsonFormatter {
14    pub fn new() -> Self {
15        Self::default()
16    }
17
18    /// Create a formatter that collects all warnings into a single JSON array
19    pub fn new_collecting() -> Self {
20        Self { collect_all: true }
21    }
22}
23
24impl OutputFormatter for JsonFormatter {
25    fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
26        if self.collect_all {
27            // For batch collection mode, just return empty string
28            // The actual JSON will be built elsewhere with all files
29            return String::new();
30        }
31
32        let json_warnings: Vec<Value> = warnings
33            .iter()
34            .map(|warning| {
35                json!({
36                    "file": file_path,
37                    "line": warning.line,
38                    "column": warning.column,
39                    "rule": warning.rule_name.as_deref().unwrap_or("unknown"),
40                    "message": warning.message,
41                    "severity": warning.severity,
42                    "fixable": warning.fix.is_some(),
43                    "fix": warning.fix.as_ref().map(|f| {
44                        json!({
45                            "range": {
46                                "start": f.range.start,
47                                "end": f.range.end
48                            },
49                            "replacement": f.replacement
50                        })
51                    })
52                })
53            })
54            .collect();
55
56        serde_json::to_string_pretty(&json_warnings).unwrap_or_default()
57    }
58}
59
60/// Format all warnings from multiple files as a single JSON array.
61///
62/// In fix mode, only remaining (unfixed) warnings are passed in,
63/// matching ESLint/Ruff convention of reporting only what's left.
64pub fn format_all_warnings_as_json(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
65    let mut json_warnings = Vec::new();
66
67    for (file_path, warnings) in all_warnings {
68        for warning in warnings {
69            json_warnings.push(json!({
70                "file": file_path,
71                "line": warning.line,
72                "column": warning.column,
73                "rule": warning.rule_name.as_deref().unwrap_or("unknown"),
74                "message": warning.message,
75                "severity": warning.severity,
76                "fixable": warning.fix.is_some(),
77                "fix": warning.fix.as_ref().map(|f| {
78                    json!({
79                        "range": {
80                            "start": f.range.start,
81                            "end": f.range.end
82                        },
83                        "replacement": f.replacement
84                    })
85                })
86            }));
87        }
88    }
89
90    serde_json::to_string_pretty(&json_warnings).unwrap_or_default()
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::rule::{Fix, Severity};
97
98    #[test]
99    fn test_json_formatter_default() {
100        let formatter = JsonFormatter::default();
101        assert!(!formatter.collect_all);
102    }
103
104    #[test]
105    fn test_json_formatter_new() {
106        let formatter = JsonFormatter::new();
107        assert!(!formatter.collect_all);
108    }
109
110    #[test]
111    fn test_json_formatter_new_collecting() {
112        let formatter = JsonFormatter::new_collecting();
113        assert!(formatter.collect_all);
114    }
115
116    #[test]
117    fn test_format_warnings_empty() {
118        let formatter = JsonFormatter::new();
119        let warnings = vec![];
120        let output = formatter.format_warnings(&warnings, "test.md");
121        assert_eq!(output, "[]");
122    }
123
124    #[test]
125    fn test_format_warnings_collecting_mode() {
126        let formatter = JsonFormatter::new_collecting();
127        let warnings = vec![LintWarning {
128            line: 1,
129            column: 1,
130            end_line: 1,
131            end_column: 5,
132            rule_name: Some("MD001".to_string()),
133            message: "Test warning".to_string(),
134            severity: Severity::Warning,
135            fix: None,
136        }];
137
138        // In collecting mode, it returns empty string
139        let output = formatter.format_warnings(&warnings, "test.md");
140        assert_eq!(output, "");
141    }
142
143    #[test]
144    fn test_format_single_warning() {
145        let formatter = JsonFormatter::new();
146        let warnings = vec![LintWarning {
147            line: 10,
148            column: 5,
149            end_line: 10,
150            end_column: 15,
151            rule_name: Some("MD001".to_string()),
152            message: "Heading levels should only increment by one level at a time".to_string(),
153            severity: Severity::Warning,
154            fix: None,
155        }];
156
157        let output = formatter.format_warnings(&warnings, "README.md");
158        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
159
160        assert_eq!(parsed.len(), 1);
161        assert_eq!(parsed[0]["file"], "README.md");
162        assert_eq!(parsed[0]["line"], 10);
163        assert_eq!(parsed[0]["column"], 5);
164        assert_eq!(parsed[0]["rule"], "MD001");
165        assert_eq!(
166            parsed[0]["message"],
167            "Heading levels should only increment by one level at a time"
168        );
169        assert_eq!(parsed[0]["severity"], "warning");
170        assert_eq!(parsed[0]["fixable"], false);
171        assert!(parsed[0]["fix"].is_null());
172    }
173
174    #[test]
175    fn test_format_warning_with_fix() {
176        let formatter = JsonFormatter::new();
177        let warnings = vec![LintWarning {
178            line: 15,
179            column: 1,
180            end_line: 15,
181            end_column: 10,
182            rule_name: Some("MD022".to_string()),
183            message: "Headings should be surrounded by blank lines".to_string(),
184            severity: Severity::Error,
185            fix: Some(Fix {
186                range: 100..110,
187                replacement: "\n# Heading\n".to_string(),
188            }),
189        }];
190
191        let output = formatter.format_warnings(&warnings, "doc.md");
192        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
193
194        assert_eq!(parsed.len(), 1);
195        assert_eq!(parsed[0]["file"], "doc.md");
196        assert_eq!(parsed[0]["line"], 15);
197        assert_eq!(parsed[0]["column"], 1);
198        assert_eq!(parsed[0]["rule"], "MD022");
199        assert_eq!(parsed[0]["message"], "Headings should be surrounded by blank lines");
200        assert_eq!(parsed[0]["severity"], "error");
201        assert_eq!(parsed[0]["fixable"], true);
202        assert!(!parsed[0]["fix"].is_null());
203        assert_eq!(parsed[0]["fix"]["range"]["start"], 100);
204        assert_eq!(parsed[0]["fix"]["range"]["end"], 110);
205        assert_eq!(parsed[0]["fix"]["replacement"], "\n# Heading\n");
206    }
207
208    #[test]
209    fn test_format_multiple_warnings() {
210        let formatter = JsonFormatter::new();
211        let warnings = vec![
212            LintWarning {
213                line: 5,
214                column: 1,
215                end_line: 5,
216                end_column: 10,
217                rule_name: Some("MD001".to_string()),
218                message: "First warning".to_string(),
219                severity: Severity::Warning,
220                fix: None,
221            },
222            LintWarning {
223                line: 10,
224                column: 3,
225                end_line: 10,
226                end_column: 20,
227                rule_name: Some("MD013".to_string()),
228                message: "Second warning".to_string(),
229                severity: Severity::Error,
230                fix: Some(Fix {
231                    range: 50..60,
232                    replacement: "fixed".to_string(),
233                }),
234            },
235        ];
236
237        let output = formatter.format_warnings(&warnings, "test.md");
238        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
239
240        assert_eq!(parsed.len(), 2);
241        assert_eq!(parsed[0]["rule"], "MD001");
242        assert_eq!(parsed[0]["message"], "First warning");
243        assert_eq!(parsed[0]["fixable"], false);
244
245        assert_eq!(parsed[1]["rule"], "MD013");
246        assert_eq!(parsed[1]["message"], "Second warning");
247        assert_eq!(parsed[1]["fixable"], true);
248    }
249
250    #[test]
251    fn test_format_warning_unknown_rule() {
252        let formatter = JsonFormatter::new();
253        let warnings = vec![LintWarning {
254            line: 1,
255            column: 1,
256            end_line: 1,
257            end_column: 5,
258            rule_name: None,
259            message: "Unknown rule warning".to_string(),
260            severity: Severity::Warning,
261            fix: None,
262        }];
263
264        let output = formatter.format_warnings(&warnings, "file.md");
265        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
266
267        assert_eq!(parsed[0]["rule"], "unknown");
268    }
269
270    #[test]
271    fn test_format_all_warnings_as_json_empty() {
272        let all_warnings: Vec<(String, Vec<LintWarning>)> = vec![];
273        let output = format_all_warnings_as_json(&all_warnings);
274        assert_eq!(output, "[]");
275    }
276
277    #[test]
278    fn test_format_all_warnings_as_json_single_file() {
279        let warnings = vec![LintWarning {
280            line: 1,
281            column: 1,
282            end_line: 1,
283            end_column: 5,
284            rule_name: Some("MD001".to_string()),
285            message: "Test warning".to_string(),
286            severity: Severity::Warning,
287            fix: None,
288        }];
289
290        let all_warnings = vec![("test.md".to_string(), warnings)];
291        let output = format_all_warnings_as_json(&all_warnings);
292        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
293
294        assert_eq!(parsed.len(), 1);
295        assert_eq!(parsed[0]["file"], "test.md");
296        assert_eq!(parsed[0]["rule"], "MD001");
297    }
298
299    #[test]
300    fn test_format_all_warnings_as_json_multiple_files() {
301        let warnings1 = vec![
302            LintWarning {
303                line: 1,
304                column: 1,
305                end_line: 1,
306                end_column: 5,
307                rule_name: Some("MD001".to_string()),
308                message: "Warning 1".to_string(),
309                severity: Severity::Warning,
310                fix: None,
311            },
312            LintWarning {
313                line: 5,
314                column: 1,
315                end_line: 5,
316                end_column: 10,
317                rule_name: Some("MD002".to_string()),
318                message: "Warning 2".to_string(),
319                severity: Severity::Warning,
320                fix: None,
321            },
322        ];
323
324        let warnings2 = vec![LintWarning {
325            line: 10,
326            column: 1,
327            end_line: 10,
328            end_column: 20,
329            rule_name: Some("MD003".to_string()),
330            message: "Warning 3".to_string(),
331            severity: Severity::Warning,
332            fix: Some(Fix {
333                range: 100..120,
334                replacement: "fixed".to_string(),
335            }),
336        }];
337
338        let all_warnings = vec![("file1.md".to_string(), warnings1), ("file2.md".to_string(), warnings2)];
339
340        let output = format_all_warnings_as_json(&all_warnings);
341        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
342
343        assert_eq!(parsed.len(), 3);
344        assert_eq!(parsed[0]["file"], "file1.md");
345        assert_eq!(parsed[0]["rule"], "MD001");
346        assert_eq!(parsed[1]["file"], "file1.md");
347        assert_eq!(parsed[1]["rule"], "MD002");
348        assert_eq!(parsed[2]["file"], "file2.md");
349        assert_eq!(parsed[2]["rule"], "MD003");
350        assert_eq!(parsed[2]["fixable"], true);
351    }
352
353    #[test]
354    fn test_json_output_is_valid() {
355        let formatter = JsonFormatter::new();
356        let warnings = vec![LintWarning {
357            line: 1,
358            column: 1,
359            end_line: 1,
360            end_column: 5,
361            rule_name: Some("MD001".to_string()),
362            message: "Test with \"quotes\" and special chars".to_string(),
363            severity: Severity::Warning,
364            fix: None,
365        }];
366
367        let output = formatter.format_warnings(&warnings, "test.md");
368
369        // Verify it's valid JSON
370        let result: Result<Vec<Value>, _> = serde_json::from_str(&output);
371        assert!(result.is_ok());
372
373        // Verify pretty printing works
374        assert!(output.contains("\n"));
375        assert!(output.contains("  "));
376    }
377
378    #[test]
379    fn test_edge_cases() {
380        let formatter = JsonFormatter::new();
381
382        // Test with large values
383        let warnings = vec![LintWarning {
384            line: 99999,
385            column: 12345,
386            end_line: 100000,
387            end_column: 12350,
388            rule_name: Some("MD999".to_string()),
389            message: "Edge case with\nnewlines\tand tabs".to_string(),
390            severity: Severity::Error,
391            fix: Some(Fix {
392                range: 999999..1000000,
393                replacement: "Multi\nline\nreplacement".to_string(),
394            }),
395        }];
396
397        let output = formatter.format_warnings(&warnings, "large.md");
398        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
399
400        assert_eq!(parsed[0]["line"], 99999);
401        assert_eq!(parsed[0]["column"], 12345);
402        assert_eq!(parsed[0]["fix"]["range"]["start"], 999999);
403        assert_eq!(parsed[0]["fix"]["range"]["end"], 1000000);
404        assert!(parsed[0]["message"].as_str().unwrap().contains("newlines\tand tabs"));
405        assert!(
406            parsed[0]["fix"]["replacement"]
407                .as_str()
408                .unwrap()
409                .contains("Multi\nline\nreplacement")
410        );
411    }
412
413    #[test]
414    fn test_severity_levels_in_json() {
415        let formatter = JsonFormatter::new();
416        let warnings = vec![
417            LintWarning {
418                line: 1,
419                column: 1,
420                end_line: 1,
421                end_column: 5,
422                rule_name: Some("MD001".to_string()),
423                message: "Error severity".to_string(),
424                severity: Severity::Error,
425                fix: None,
426            },
427            LintWarning {
428                line: 2,
429                column: 1,
430                end_line: 2,
431                end_column: 5,
432                rule_name: Some("MD002".to_string()),
433                message: "Warning severity".to_string(),
434                severity: Severity::Warning,
435                fix: None,
436            },
437            LintWarning {
438                line: 3,
439                column: 1,
440                end_line: 3,
441                end_column: 5,
442                rule_name: Some("MD003".to_string()),
443                message: "Info severity".to_string(),
444                severity: Severity::Info,
445                fix: None,
446            },
447        ];
448
449        let output = formatter.format_warnings(&warnings, "test.md");
450        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
451
452        assert_eq!(parsed.len(), 3);
453        assert_eq!(parsed[0]["severity"], "error");
454        assert_eq!(parsed[1]["severity"], "warning");
455        assert_eq!(parsed[2]["severity"], "info");
456    }
457}