supa_mdx_lint/output/
markdown.rs

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        // Create first file
421        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        // Create second file
426        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        // Check file1 content appears in output
458        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        // Check file2 content appears in output
463        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        // Create a long markdown file with 100 lines
471        let mut contents = String::with_capacity(2000);
472        for i in 1..=100 {
473            contents.push_str(&format!("# Line {}\n", i));
474        }
475
476        // Place error somewhere in the middle
477        let middle_line = 50;
478        let start_pos = contents.find(&format!("# Line {}", middle_line)).unwrap();
479        let end_pos = start_pos + 15; // Capture this line and part of the next
480        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        // Verify the error is properly formatted
491        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        // Verify we don't have the entire file in the output
496        assert!(!output.contains("# Line 1"));
497        assert!(!output.contains("# Line 100"));
498
499        // But we should have a reasonable context around the error
500        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}