1use std::{collections::HashMap, fs};
2
3use anyhow::Result;
4
5use crate::{
6 errors::LintError,
7 fix::LintCorrection,
8 output::OutputFormatter,
9 rope::Rope,
10 utils::{escape_backticks, num_digits, pluralize},
11 ConfigMetadata, LintLevel, LintOutput,
12};
13
14use super::OutputSummary;
15
16#[derive(Debug, Clone)]
17pub struct MarkdownFormatter;
18
19impl OutputFormatter for MarkdownFormatter {
20 fn id(&self) -> &'static str {
21 "markdown"
22 }
23
24 fn should_log_metadata(&self) -> bool {
25 true
26 }
27
28 fn format(&self, output: &[LintOutput], metadata: &ConfigMetadata) -> Result<String> {
29 let mut result = String::new();
30 result.push_str("# supa-mdx-lint results\n\n");
31
32 for output in output {
33 if output.errors.is_empty() {
34 continue;
35 }
36 result.push_str(&format!("## {}\n\n", output.file_path));
37 for error in &output.errors {
38 result.push_str(&self.format_error(
39 &output.file_path,
40 error,
41 metadata.config_file_locations.as_ref(),
42 )?);
43 }
44 }
45
46 result.push_str(&self.format_summary(output));
47 Ok(result)
48 }
49}
50
51impl MarkdownFormatter {
52 fn format_error(
53 &self,
54 file_path: &str,
55 error: &LintError,
56 config_file_locations: Option<&HashMap<String, String>>,
57 ) -> Result<String> {
58 let mut result = String::new();
59 result.push_str(&format!(
60 "### {}\n\n",
61 match error.level {
62 LintLevel::Warning => "Warning",
63 LintLevel::Error => "Error",
64 }
65 ));
66 result.push_str("```\n");
67 result.push_str(&self.get_error_snippet(file_path, error)?);
68 result.push_str("```\n\n");
69 result.push_str(&format!("[{}] {}\n", error.rule, error.message));
70 if let Some(config_file_location) =
71 config_file_locations.and_then(|locations| locations.get(&error.rule))
72 {
73 result.push_str(&format!(
74 " (customize configuration at {})\n",
75 config_file_location
76 ));
77 }
78 result.push('\n');
79 if let Some(rec_text) = self.get_recommendations_text(error) {
80 result.push_str(&rec_text);
81 }
82 result.push('\n');
83 Ok(result)
84 }
85
86 fn get_error_snippet(&self, file_path: &str, error: &LintError) -> Result<String> {
87 let content = Rope::from(fs::read_to_string(file_path)?);
88 let start_row = error.location.start.row;
89 let end_row = error
90 .location
91 .end
92 .row
93 .saturating_add(1)
94 .min(content.line_len() - 1);
95
96 let col_num_width = num_digits(end_row);
97 let mut result = String::new();
98 for row in start_row..=end_row {
99 let line = content.line(row);
100 let line_number_str = format!("{:width$}", row + 1, width = col_num_width);
101 result += &format!("{} | {}\n", line_number_str, line);
102 }
103 Ok(result)
104 }
105
106 fn get_recommendations_text(&self, error: &LintError) -> Option<String> {
107 let rec_length = error.fix.as_ref().map_or(0, |fix| fix.len())
108 + error.suggestions.as_ref().map_or(0, |sug| sug.len());
109 let all_recommendations = match (error.fix.as_ref(), error.suggestions.as_ref()) {
110 (None, None) => None,
111 (fix, suggestions) => {
112 let mut combined = Vec::with_capacity(rec_length);
113 if let Some(f) = fix {
114 combined.extend(f.iter());
115 }
116 if let Some(s) = suggestions {
117 combined.extend(s.iter());
118 }
119 Some(combined)
120 }
121 }?;
122
123 let mut result = "#### Recommendations\n\n".to_string();
124 let line_number_width = num_digits(all_recommendations.len());
125 all_recommendations
126 .iter()
127 .enumerate()
128 .for_each(|(idx, rec)| {
129 result += &format!(
130 "{:width$}. {}\n",
131 idx + 1,
132 self.get_recommendation_text(rec),
133 width = line_number_width
134 );
135 });
136
137 Some(result)
138 }
139
140 fn get_recommendation_text(&self, corr: &LintCorrection) -> String {
141 match corr {
142 LintCorrection::Insert(ins) => {
143 format!(
144 "Insert the following text at row {}, column {}: `{}`",
145 ins.location.start.row + 1,
146 ins.location.start.column + 1,
147 escape_backticks(&ins.text)
148 )
149 }
150 LintCorrection::Delete(del) => {
151 format!(
152 "Delete the text from row {}, column {} to row {}, column {}",
153 del.location.start.row + 1,
154 del.location.start.column + 1,
155 del.location.end.row + 1,
156 del.location.end.column + 1
157 )
158 }
159 LintCorrection::Replace(rep) => {
160 format!(
161 "Replace the text from row {}, column {} to row {}, column {} with `{}`",
162 rep.location.start.row + 1,
163 rep.location.start.column + 1,
164 rep.location.end.row + 1,
165 rep.location.end.column + 1,
166 escape_backticks(&rep.text)
167 )
168 }
169 }
170 }
171
172 fn format_summary(&self, output: &[LintOutput]) -> String {
173 let mut result = String::new();
174 let OutputSummary {
175 num_files,
176 num_errors,
177 num_warnings,
178 } = self.get_summary(output);
179 result.push_str("## Summary\n\n");
180 result.push_str(&format!(
181 "- 🤖 {num_files} file{} linted\n",
182 pluralize(num_files)
183 ));
184 result.push_str(&format!(
185 "- 🚨 {num_errors} error{}\n",
186 pluralize(num_errors)
187 ));
188 result.push_str(&format!(
189 "- 🔔 {num_warnings} warning{}\n",
190 pluralize(num_warnings)
191 ));
192 result
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use bon::builder;
199 use tempfile::TempDir;
200
201 use super::*;
202 use crate::{
203 errors::{LintError, LintLevel},
204 fix::{LintCorrectionDelete, LintCorrectionInsert, LintCorrectionReplace},
205 location::DenormalizedLocation,
206 };
207
208 #[builder]
209 fn format_mock_error(
210 contents: &str,
211 location: DenormalizedLocation,
212 fix: Option<Vec<LintCorrection>>,
213 sugg: Option<Vec<LintCorrection>>,
214 #[builder(default = "test.md")] mock_path: &str,
215 #[builder(default = LintLevel::Error)] level: LintLevel,
216 #[builder(default = "MockRule")] rule_name: &str,
217 #[builder(default = "This is an error")] error_message: &str,
218 ) -> Result<String> {
219 let temp_dir = TempDir::new().unwrap();
220 let file_path = temp_dir.path().join(mock_path);
221 fs::write(&file_path, &contents).unwrap();
222
223 let error = LintError::from_raw_location()
224 .rule(rule_name)
225 .level(level)
226 .message(error_message)
227 .location(location)
228 .maybe_fix(fix)
229 .maybe_suggestions(sugg)
230 .call();
231
232 let file_path = file_path.to_string_lossy().to_string();
233 let output = LintOutput {
234 file_path: file_path.clone(),
235 errors: vec![error],
236 };
237 let output = vec![output];
238
239 let formatter = MarkdownFormatter;
240 formatter.format(&output, &ConfigMetadata::default())
241 }
242
243 #[test]
244 fn test_markdown_formatter() {
245 let contents = r#"# Hello World
246
247What a wonderful world!"#;
248 let location = DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13);
249 let output = format_mock_error()
250 .contents(contents)
251 .location(location)
252 .call()
253 .unwrap();
254
255 assert!(output.starts_with("# supa-mdx-lint"));
256 assert!(output.contains("1 | # Hello World"));
257 assert!(output.contains("This is an error"));
258 }
259
260 #[test]
261 fn test_markdown_formatter_replace() {
262 let contents = r#"# Hello World
263
264What a wonderful world!"#;
265 let location = DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13);
266 let fix = vec![LintCorrection::Replace(LintCorrectionReplace {
267 location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
268 text: "Friend".to_string(),
269 })];
270 let output = format_mock_error()
271 .contents(contents)
272 .location(location)
273 .fix(fix)
274 .call()
275 .unwrap();
276
277 assert!(output.starts_with("# supa-mdx-lint"));
278 assert!(output.contains("1 | # Hello World"));
279 assert!(output.contains("This is an error"));
280 assert!(output.contains("Recommendations"));
281 assert!(output.contains("Replace the text"));
282 assert!(output.contains("Friend"));
283 }
284
285 #[test]
286 fn test_markdown_formatter_insert() {
287 let contents = r#"# Hello World
288
289What a wonderful world!"#;
290 let location = DenormalizedLocation::dummy(21, 21, 2, 6, 2, 6);
291 let fix = vec![LintCorrection::Insert(LintCorrectionInsert {
292 location: DenormalizedLocation::dummy(21, 21, 2, 6, 2, 6),
293 text: " super".to_string(),
294 })];
295 let output = format_mock_error()
296 .contents(contents)
297 .location(location)
298 .fix(fix)
299 .call()
300 .unwrap();
301
302 assert!(output.starts_with("# supa-mdx-lint"));
303 assert!(output.contains("3 | What a wonderful world!"));
304 assert!(output.contains("This is an error"));
305 assert!(output.contains("Recommendations"));
306 assert!(output.contains("Insert the following text"));
307 assert!(output.contains("super"));
308 }
309
310 #[test]
311 fn test_markdown_formatter_delete() {
312 let contents = r#"# Hello World
313
314What a wonderful world!"#;
315 let location = DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13);
316 let fix = vec![LintCorrection::Delete(LintCorrectionDelete {
317 location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
318 })];
319 let output = format_mock_error()
320 .contents(contents)
321 .location(location)
322 .fix(fix)
323 .call()
324 .unwrap();
325
326 assert!(output.starts_with("# supa-mdx-lint"));
327 assert!(output.contains("1 | # Hello World"));
328 assert!(output.contains("This is an error"));
329 assert!(output.contains("Recommendations"));
330 assert!(output.contains("Delete the text"));
331 assert!(output.contains("row 1, column 9 to row 1, column 14"));
332 }
333
334 #[test]
335 fn test_markdown_formatter_multiple_recommendations() {
336 let contents = r#"# Hello World
337
338What a wonderful world!"#;
339 let location = DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13);
340 let fix = vec![LintCorrection::Replace(LintCorrectionReplace {
341 location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
342 text: "Friend".to_string(),
343 })];
344 let suggestions = vec![
345 LintCorrection::Replace(LintCorrectionReplace {
346 location: DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13),
347 text: "Neighbor".to_string(),
348 }),
349 LintCorrection::Insert(LintCorrectionInsert {
350 location: DenormalizedLocation::dummy(13, 13, 0, 13, 0, 13),
351 text: " and `Universe`".to_string(),
352 }),
353 ];
354
355 let output = format_mock_error()
356 .contents(contents)
357 .location(location)
358 .fix(fix)
359 .sugg(suggestions)
360 .call()
361 .unwrap();
362
363 assert!(output.starts_with("# supa-mdx-lint"));
364 assert!(output.contains("1 | # Hello World"));
365 assert!(output.contains("This is an error"));
366 assert!(output.contains("Recommendations"));
367 assert!(output.contains(
368 "1. Replace the text from row 1, column 9 to row 1, column 14 with `Friend`"
369 ));
370 assert!(output.contains(
371 "2. Replace the text from row 1, column 9 to row 1, column 14 with `Neighbor`"
372 ));
373 assert!(output
374 .contains("3. Insert the following text at row 1, column 14: ` and \\`Universe\\``"));
375 }
376
377 #[test]
378 fn test_markdown_formatter_multiple_errors() {
379 let contents = r#"# Hello World
380
381What a wonderful world!"#;
382 let temp_dir = TempDir::new().unwrap();
383 let file_path = temp_dir.path().join("test.md");
384 fs::write(&file_path, contents).unwrap();
385
386 let output = LintOutput {
387 file_path: file_path.to_string_lossy().to_string(),
388 errors: vec![
389 LintError::from_raw_location()
390 .rule("FirstRule")
391 .level(LintLevel::Error)
392 .message("First error message")
393 .location(DenormalizedLocation::dummy(8, 13, 0, 8, 0, 13))
394 .call(),
395 LintError::from_raw_location()
396 .rule("SecondRule")
397 .level(LintLevel::Warning)
398 .message("Second error message")
399 .location(DenormalizedLocation::dummy(21, 30, 2, 6, 2, 15))
400 .call(),
401 ],
402 };
403
404 let formatter = MarkdownFormatter;
405 let output_str = formatter
406 .format(&[output], &ConfigMetadata::default())
407 .unwrap();
408
409 assert!(output_str.starts_with("# supa-mdx-lint"));
410 assert!(output_str.contains("1 | # Hello World"));
411 assert!(output_str.contains("First error message"));
412 assert!(output_str.contains("3 | What a wonderful world!"));
413 assert!(output_str.contains("Second error message"));
414 }
415
416 #[test]
417 fn test_markdown_formatter_multiple_files() {
418 let temp_dir = TempDir::new().unwrap();
419
420 let file_path1 = temp_dir.path().join("file1.md");
422 let contents1 = "# First File\nThis is the first file.";
423 fs::write(&file_path1, contents1).unwrap();
424
425 let file_path2 = temp_dir.path().join("file2.md");
427 let contents2 = "# Second File\nThis is the second file.";
428 fs::write(&file_path2, contents2).unwrap();
429
430 let output1 = LintOutput {
431 file_path: file_path1.to_string_lossy().to_string(),
432 errors: vec![LintError::from_raw_location()
433 .rule("Rule1")
434 .level(LintLevel::Error)
435 .message("Error in first file")
436 .location(DenormalizedLocation::dummy(0, 10, 0, 0, 0, 10))
437 .call()],
438 };
439
440 let output2 = LintOutput {
441 file_path: file_path2.to_string_lossy().to_string(),
442 errors: vec![LintError::from_raw_location()
443 .rule("Rule2")
444 .level(LintLevel::Warning)
445 .message("Warning in second file")
446 .location(DenormalizedLocation::dummy(0, 12, 0, 0, 0, 12))
447 .call()],
448 };
449
450 let formatter = MarkdownFormatter;
451 let output_str = formatter
452 .format(&[output1, output2], &ConfigMetadata::default())
453 .unwrap();
454
455 assert!(output_str.starts_with("# supa-mdx-lint"));
456
457 assert!(output_str.contains("file1.md"));
459 assert!(output_str.contains("1 | # First File"));
460 assert!(output_str.contains("Error in first file"));
461
462 assert!(output_str.contains("file2.md"));
464 assert!(output_str.contains("1 | # Second File"));
465 assert!(output_str.contains("Warning in second file"));
466 }
467
468 #[test]
469 fn test_markdown_formatter_long_file() {
470 let mut contents = String::with_capacity(2000);
472 for i in 1..=100 {
473 contents.push_str(&format!("# Line {}\n", i));
474 }
475
476 let middle_line = 50;
478 let start_pos = contents.find(&format!("# Line {}", middle_line)).unwrap();
479 let end_pos = start_pos + 15; let location =
481 DenormalizedLocation::dummy(start_pos, end_pos, middle_line - 1, 0, middle_line, 4);
482
483 let output = format_mock_error()
484 .contents(&contents)
485 .location(location)
486 .error_message("Error in a long file")
487 .call()
488 .unwrap();
489
490 assert!(output.starts_with("# supa-mdx-lint"));
492 assert!(output.contains(&format!("{} | # Line {}", middle_line, middle_line)));
493 assert!(output.contains("Error in a long file"));
494
495 assert!(!output.contains("# Line 1"));
497 assert!(!output.contains("# Line 100"));
498
499 assert!(output.contains(&format!("{} | # Line {}", middle_line + 1, middle_line + 1)));
501 assert!(output.contains(&format!("{} | # Line {}", middle_line + 2, middle_line + 2)));
502 }
503}