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(fix_to_json),
44                })
45            })
46            .collect();
47
48        serde_json::to_string_pretty(&json_warnings).unwrap_or_default()
49    }
50}
51
52fn fix_to_json(fix: &crate::rule::Fix) -> serde_json::Value {
53    let mut obj = json!({
54        "range": {
55            "start": fix.range.start,
56            "end": fix.range.end,
57        },
58        "replacement": fix.replacement,
59    });
60    if !fix.additional_edits.is_empty() {
61        obj["additional_edits"] = serde_json::Value::Array(fix.additional_edits.iter().map(fix_to_json).collect());
62    }
63    obj
64}
65
66/// Format all warnings from multiple files as a single JSON array.
67///
68/// In fix mode, only remaining (unfixed) warnings are passed in,
69/// matching ESLint/Ruff convention of reporting only what's left.
70pub fn format_all_warnings_as_json(all_warnings: &[(String, Vec<LintWarning>)]) -> String {
71    let mut json_warnings = Vec::new();
72
73    for (file_path, warnings) in all_warnings {
74        for warning in warnings {
75            json_warnings.push(json!({
76                "file": file_path,
77                "line": warning.line,
78                "column": warning.column,
79                "rule": warning.rule_name.as_deref().unwrap_or("unknown"),
80                "message": warning.message,
81                "severity": warning.severity,
82                "fixable": warning.fix.is_some(),
83                "fix": warning.fix.as_ref().map(fix_to_json),
84            }));
85        }
86    }
87
88    serde_json::to_string_pretty(&json_warnings).unwrap_or_default()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::rule::{Fix, Severity};
95
96    #[test]
97    fn test_json_formatter_default() {
98        let formatter = JsonFormatter::default();
99        assert!(!formatter.collect_all);
100    }
101
102    #[test]
103    fn test_json_formatter_new() {
104        let formatter = JsonFormatter::new();
105        assert!(!formatter.collect_all);
106    }
107
108    #[test]
109    fn test_json_formatter_new_collecting() {
110        let formatter = JsonFormatter::new_collecting();
111        assert!(formatter.collect_all);
112    }
113
114    #[test]
115    fn test_format_warnings_empty() {
116        let formatter = JsonFormatter::new();
117        let warnings = vec![];
118        let output = formatter.format_warnings(&warnings, "test.md");
119        assert_eq!(output, "[]");
120    }
121
122    #[test]
123    fn test_format_warnings_collecting_mode() {
124        let formatter = JsonFormatter::new_collecting();
125        let warnings = vec![LintWarning {
126            line: 1,
127            column: 1,
128            end_line: 1,
129            end_column: 5,
130            rule_name: Some("MD001".to_string()),
131            message: "Test warning".to_string(),
132            severity: Severity::Warning,
133            fix: None,
134        }];
135
136        // In collecting mode, it returns empty string
137        let output = formatter.format_warnings(&warnings, "test.md");
138        assert_eq!(output, "");
139    }
140
141    #[test]
142    fn test_format_single_warning() {
143        let formatter = JsonFormatter::new();
144        let warnings = vec![LintWarning {
145            line: 10,
146            column: 5,
147            end_line: 10,
148            end_column: 15,
149            rule_name: Some("MD001".to_string()),
150            message: "Heading levels should only increment by one level at a time".to_string(),
151            severity: Severity::Warning,
152            fix: None,
153        }];
154
155        let output = formatter.format_warnings(&warnings, "README.md");
156        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
157
158        assert_eq!(parsed.len(), 1);
159        assert_eq!(parsed[0]["file"], "README.md");
160        assert_eq!(parsed[0]["line"], 10);
161        assert_eq!(parsed[0]["column"], 5);
162        assert_eq!(parsed[0]["rule"], "MD001");
163        assert_eq!(
164            parsed[0]["message"],
165            "Heading levels should only increment by one level at a time"
166        );
167        assert_eq!(parsed[0]["severity"], "warning");
168        assert_eq!(parsed[0]["fixable"], false);
169        assert!(parsed[0]["fix"].is_null());
170    }
171
172    #[test]
173    fn test_format_warning_with_fix() {
174        let formatter = JsonFormatter::new();
175        let warnings = vec![LintWarning {
176            line: 15,
177            column: 1,
178            end_line: 15,
179            end_column: 10,
180            rule_name: Some("MD022".to_string()),
181            message: "Headings should be surrounded by blank lines".to_string(),
182            severity: Severity::Error,
183            fix: Some(Fix::new(100..110, "\n# Heading\n".to_string())),
184        }];
185
186        let output = formatter.format_warnings(&warnings, "doc.md");
187        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
188
189        assert_eq!(parsed.len(), 1);
190        assert_eq!(parsed[0]["file"], "doc.md");
191        assert_eq!(parsed[0]["line"], 15);
192        assert_eq!(parsed[0]["column"], 1);
193        assert_eq!(parsed[0]["rule"], "MD022");
194        assert_eq!(parsed[0]["message"], "Headings should be surrounded by blank lines");
195        assert_eq!(parsed[0]["severity"], "error");
196        assert_eq!(parsed[0]["fixable"], true);
197        assert!(!parsed[0]["fix"].is_null());
198        assert_eq!(parsed[0]["fix"]["range"]["start"], 100);
199        assert_eq!(parsed[0]["fix"]["range"]["end"], 110);
200        assert_eq!(parsed[0]["fix"]["replacement"], "\n# Heading\n");
201    }
202
203    #[test]
204    fn test_format_warning_with_additional_edits() {
205        // Models MD054 ref-emit: a fix with one additional_edit. The JSON
206        // emitter must surface the secondary edit so external consumers
207        // (CI tooling, editors that drive rumdl over JSON, etc.) can apply
208        // the full atomic fix rather than only the primary range.
209        let formatter = JsonFormatter::new();
210        let warnings = vec![LintWarning {
211            line: 1,
212            column: 5,
213            end_line: 1,
214            end_column: 32,
215            rule_name: Some("MD054".to_string()),
216            message: "Inconsistent link style".to_string(),
217            severity: Severity::Warning,
218            fix: Some(Fix::with_additional_edits(
219                4..31,
220                "[docs]".to_string(),
221                vec![Fix::new(45..45, "\n[docs]: https://example.com\n".to_string())],
222            )),
223        }];
224
225        let output = formatter.format_warnings(&warnings, "doc.md");
226        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
227
228        assert_eq!(parsed[0]["fixable"], true);
229        assert_eq!(parsed[0]["fix"]["range"]["start"], 4);
230        assert_eq!(parsed[0]["fix"]["range"]["end"], 31);
231        assert_eq!(parsed[0]["fix"]["replacement"], "[docs]");
232
233        let extras = parsed[0]["fix"]["additional_edits"]
234            .as_array()
235            .expect("additional_edits should serialize as an array when non-empty");
236        assert_eq!(extras.len(), 1);
237        assert_eq!(extras[0]["range"]["start"], 45);
238        assert_eq!(extras[0]["range"]["end"], 45);
239        assert_eq!(extras[0]["replacement"], "\n[docs]: https://example.com\n");
240    }
241
242    #[test]
243    fn test_format_warning_omits_empty_additional_edits() {
244        // For the common single-edit case, additional_edits must NOT appear in
245        // the JSON output (skip_serializing_if = "Vec::is_empty"). Verifying
246        // this protects external consumers from churn on every warning.
247        let formatter = JsonFormatter::new();
248        let warnings = vec![LintWarning {
249            line: 1,
250            column: 1,
251            end_line: 1,
252            end_column: 5,
253            rule_name: Some("MD009".to_string()),
254            message: "Trailing whitespace".to_string(),
255            severity: Severity::Warning,
256            fix: Some(Fix::new(0..2, " ".to_string())),
257        }];
258
259        let output = formatter.format_warnings(&warnings, "doc.md");
260        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
261
262        let fix = &parsed[0]["fix"];
263        assert!(
264            fix.get("additional_edits").is_none(),
265            "additional_edits must be omitted when empty, got: {fix}"
266        );
267    }
268
269    #[test]
270    fn test_format_multiple_warnings() {
271        let formatter = JsonFormatter::new();
272        let warnings = vec![
273            LintWarning {
274                line: 5,
275                column: 1,
276                end_line: 5,
277                end_column: 10,
278                rule_name: Some("MD001".to_string()),
279                message: "First warning".to_string(),
280                severity: Severity::Warning,
281                fix: None,
282            },
283            LintWarning {
284                line: 10,
285                column: 3,
286                end_line: 10,
287                end_column: 20,
288                rule_name: Some("MD013".to_string()),
289                message: "Second warning".to_string(),
290                severity: Severity::Error,
291                fix: Some(Fix::new(50..60, "fixed".to_string())),
292            },
293        ];
294
295        let output = formatter.format_warnings(&warnings, "test.md");
296        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
297
298        assert_eq!(parsed.len(), 2);
299        assert_eq!(parsed[0]["rule"], "MD001");
300        assert_eq!(parsed[0]["message"], "First warning");
301        assert_eq!(parsed[0]["fixable"], false);
302
303        assert_eq!(parsed[1]["rule"], "MD013");
304        assert_eq!(parsed[1]["message"], "Second warning");
305        assert_eq!(parsed[1]["fixable"], true);
306    }
307
308    #[test]
309    fn test_format_warning_unknown_rule() {
310        let formatter = JsonFormatter::new();
311        let warnings = vec![LintWarning {
312            line: 1,
313            column: 1,
314            end_line: 1,
315            end_column: 5,
316            rule_name: None,
317            message: "Unknown rule warning".to_string(),
318            severity: Severity::Warning,
319            fix: None,
320        }];
321
322        let output = formatter.format_warnings(&warnings, "file.md");
323        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
324
325        assert_eq!(parsed[0]["rule"], "unknown");
326    }
327
328    #[test]
329    fn test_format_all_warnings_as_json_empty() {
330        let all_warnings: Vec<(String, Vec<LintWarning>)> = vec![];
331        let output = format_all_warnings_as_json(&all_warnings);
332        assert_eq!(output, "[]");
333    }
334
335    #[test]
336    fn test_format_all_warnings_as_json_single_file() {
337        let warnings = vec![LintWarning {
338            line: 1,
339            column: 1,
340            end_line: 1,
341            end_column: 5,
342            rule_name: Some("MD001".to_string()),
343            message: "Test warning".to_string(),
344            severity: Severity::Warning,
345            fix: None,
346        }];
347
348        let all_warnings = vec![("test.md".to_string(), warnings)];
349        let output = format_all_warnings_as_json(&all_warnings);
350        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
351
352        assert_eq!(parsed.len(), 1);
353        assert_eq!(parsed[0]["file"], "test.md");
354        assert_eq!(parsed[0]["rule"], "MD001");
355    }
356
357    #[test]
358    fn test_format_all_warnings_as_json_multiple_files() {
359        let warnings1 = vec![
360            LintWarning {
361                line: 1,
362                column: 1,
363                end_line: 1,
364                end_column: 5,
365                rule_name: Some("MD001".to_string()),
366                message: "Warning 1".to_string(),
367                severity: Severity::Warning,
368                fix: None,
369            },
370            LintWarning {
371                line: 5,
372                column: 1,
373                end_line: 5,
374                end_column: 10,
375                rule_name: Some("MD002".to_string()),
376                message: "Warning 2".to_string(),
377                severity: Severity::Warning,
378                fix: None,
379            },
380        ];
381
382        let warnings2 = vec![LintWarning {
383            line: 10,
384            column: 1,
385            end_line: 10,
386            end_column: 20,
387            rule_name: Some("MD003".to_string()),
388            message: "Warning 3".to_string(),
389            severity: Severity::Warning,
390            fix: Some(Fix::new(100..120, "fixed".to_string())),
391        }];
392
393        let all_warnings = vec![("file1.md".to_string(), warnings1), ("file2.md".to_string(), warnings2)];
394
395        let output = format_all_warnings_as_json(&all_warnings);
396        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
397
398        assert_eq!(parsed.len(), 3);
399        assert_eq!(parsed[0]["file"], "file1.md");
400        assert_eq!(parsed[0]["rule"], "MD001");
401        assert_eq!(parsed[1]["file"], "file1.md");
402        assert_eq!(parsed[1]["rule"], "MD002");
403        assert_eq!(parsed[2]["file"], "file2.md");
404        assert_eq!(parsed[2]["rule"], "MD003");
405        assert_eq!(parsed[2]["fixable"], true);
406    }
407
408    #[test]
409    fn test_json_output_is_valid() {
410        let formatter = JsonFormatter::new();
411        let warnings = vec![LintWarning {
412            line: 1,
413            column: 1,
414            end_line: 1,
415            end_column: 5,
416            rule_name: Some("MD001".to_string()),
417            message: "Test with \"quotes\" and special chars".to_string(),
418            severity: Severity::Warning,
419            fix: None,
420        }];
421
422        let output = formatter.format_warnings(&warnings, "test.md");
423
424        // Verify it's valid JSON
425        let result: Result<Vec<Value>, _> = serde_json::from_str(&output);
426        assert!(result.is_ok());
427
428        // Verify pretty printing works
429        assert!(output.contains('\n'));
430        assert!(output.contains("  "));
431    }
432
433    #[test]
434    fn test_edge_cases() {
435        let formatter = JsonFormatter::new();
436
437        // Test with large values
438        let warnings = vec![LintWarning {
439            line: 99999,
440            column: 12345,
441            end_line: 100000,
442            end_column: 12350,
443            rule_name: Some("MD999".to_string()),
444            message: "Edge case with\nnewlines\tand tabs".to_string(),
445            severity: Severity::Error,
446            fix: Some(Fix::new(999999..1000000, "Multi\nline\nreplacement".to_string())),
447        }];
448
449        let output = formatter.format_warnings(&warnings, "large.md");
450        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
451
452        assert_eq!(parsed[0]["line"], 99999);
453        assert_eq!(parsed[0]["column"], 12345);
454        assert_eq!(parsed[0]["fix"]["range"]["start"], 999999);
455        assert_eq!(parsed[0]["fix"]["range"]["end"], 1000000);
456        assert!(parsed[0]["message"].as_str().unwrap().contains("newlines\tand tabs"));
457        assert!(
458            parsed[0]["fix"]["replacement"]
459                .as_str()
460                .unwrap()
461                .contains("Multi\nline\nreplacement")
462        );
463    }
464
465    #[test]
466    fn test_severity_levels_in_json() {
467        let formatter = JsonFormatter::new();
468        let warnings = vec![
469            LintWarning {
470                line: 1,
471                column: 1,
472                end_line: 1,
473                end_column: 5,
474                rule_name: Some("MD001".to_string()),
475                message: "Error severity".to_string(),
476                severity: Severity::Error,
477                fix: None,
478            },
479            LintWarning {
480                line: 2,
481                column: 1,
482                end_line: 2,
483                end_column: 5,
484                rule_name: Some("MD002".to_string()),
485                message: "Warning severity".to_string(),
486                severity: Severity::Warning,
487                fix: None,
488            },
489            LintWarning {
490                line: 3,
491                column: 1,
492                end_line: 3,
493                end_column: 5,
494                rule_name: Some("MD003".to_string()),
495                message: "Info severity".to_string(),
496                severity: Severity::Info,
497                fix: None,
498            },
499        ];
500
501        let output = formatter.format_warnings(&warnings, "test.md");
502        let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
503
504        assert_eq!(parsed.len(), 3);
505        assert_eq!(parsed[0]["severity"], "error");
506        assert_eq!(parsed[1]["severity"], "warning");
507        assert_eq!(parsed[2]["severity"], "info");
508    }
509}