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