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