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