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