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