Skip to main content

rumdl_lib/output/formatters/
json.rs

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