1use crate::output::OutputFormatter;
4use crate::rule::{LintWarning, Severity};
5use serde::Serialize;
6use std::borrow::Cow;
7
8#[derive(Default)]
10pub struct JsonFormatter {
11 collect_all: bool,
12}
13
14impl JsonFormatter {
15 pub fn new() -> Self {
16 Self::default()
17 }
18
19 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 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#[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
95pub 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
123pub 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 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 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 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 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 let result: Result<Vec<Value>, _> = serde_json::from_str(&output);
528 assert!(result.is_ok());
529
530 assert!(output.contains('\n'));
532 assert!(output.contains(" "));
533 }
534
535 #[test]
536 fn test_edge_cases() {
537 let formatter = JsonFormatter::new();
538
539 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}