1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use serde_json::{Value, json};
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<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
72pub 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
97pub 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 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 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 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 let result: Result<Vec<Value>, _> = serde_json::from_str(&output);
448 assert!(result.is_ok());
449
450 assert!(output.contains('\n'));
452 assert!(output.contains(" "));
453 }
454
455 #[test]
456 fn test_edge_cases() {
457 let formatter = JsonFormatter::new();
458
459 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}