supa_mdx_lint/output/
simple.rs

1use anyhow::Result;
2
3use crate::{output::OutputFormatter, ConfigMetadata};
4
5use super::{LintOutput, OutputSummary};
6
7/// Outputs linter diagnostics in the simple format, for CLI display, which has
8/// the structure:
9///
10/// ```text
11/// <file path>:<line>:<column>: [<severity>] <msg>
12/// ```
13///
14/// The diagnostics are followed by a summary of the number of linted files,
15/// total errors, and total warnings.
16#[derive(Debug, Clone)]
17pub struct SimpleFormatter;
18
19impl OutputFormatter for SimpleFormatter {
20    fn id(&self) -> &'static str {
21        "simple"
22    }
23
24    fn format(&self, output: &[LintOutput], _metadata: &ConfigMetadata) -> Result<String> {
25        let mut result = String::new();
26        // Whether anything has been written to the output, used to determine
27        // whether to write a newline before the summary.
28        let mut written = false;
29
30        for output in output.iter() {
31            for error in output.errors.iter() {
32                written |= true;
33
34                result.push_str(&format!(
35                    "{}:{}:{}: [{}] {}\n",
36                    output.file_path,
37                    error.location.start.row + 1,
38                    error.location.start.column + 1,
39                    error.level,
40                    error.message,
41                ));
42            }
43        }
44
45        if written {
46            result.push('\n');
47        }
48        result.push_str(&self.format_summary(output));
49
50        Ok(result)
51    }
52
53    fn should_log_metadata(&self) -> bool {
54        true
55    }
56}
57
58impl SimpleFormatter {
59    fn format_summary(&self, output: &[LintOutput]) -> String {
60        let mut result = String::new();
61        let OutputSummary {
62            num_errors,
63            num_files,
64            num_warnings,
65        } = self.get_summary(output);
66
67        let diagnostic_message = match (num_errors, num_warnings) {
68            (0, 0) => "🟢 No errors or warnings found",
69            (0, num_warnings) => &format!(
70                "🟡 Found {} warning{}",
71                num_warnings,
72                if num_warnings != 1 { "s" } else { "" }
73            ),
74            (num_errors, 0) => &format!(
75                "🔴 Found {} error{}",
76                num_errors,
77                if num_errors != 1 { "s" } else { "" }
78            ),
79            (num_errors, num_warnings) => &format!(
80                "🔴 Found {} error{} and {} warning{}",
81                num_errors,
82                if num_errors != 1 { "s" } else { "" },
83                num_warnings,
84                if num_warnings != 1 { "s" } else { "" }
85            ),
86        };
87
88        result.push_str(&format!(
89            "🔍 {} source{} linted\n",
90            num_files,
91            if num_files != 1 { "s" } else { "" }
92        ));
93        result.push_str(&format!("{}\n", diagnostic_message));
94        result
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::{
102        errors::{LintError, LintLevel},
103        location::DenormalizedLocation,
104    };
105
106    #[test]
107    fn test_simple_formatter() {
108        let file_path = "test.md".to_string();
109        let error = LintError::from_raw_location()
110            .rule("MockRule")
111            .level(LintLevel::Error)
112            .message("This is an error")
113            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
114            .call();
115
116        let output = LintOutput {
117            file_path,
118            errors: vec![error],
119        };
120        let output = vec![output];
121
122        let formatter = SimpleFormatter;
123        let result = formatter
124            .format(&output, &ConfigMetadata::default())
125            .unwrap();
126        assert_eq!(
127            result,
128            "test.md:1:1: [ERROR] This is an error\n\n🔍 1 source linted\n🔴 Found 1 error\n"
129        );
130    }
131
132    #[test]
133    fn test_simple_formatter_warning() {
134        let file_path = "test.md".to_string();
135        let error = LintError::from_raw_location()
136            .rule("MockRule")
137            .level(LintLevel::Warning)
138            .message("This is a warning")
139            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
140            .call();
141        let output = LintOutput {
142            file_path,
143            errors: vec![error],
144        };
145        let output = vec![output];
146
147        let formatter = SimpleFormatter;
148        let result = formatter
149            .format(&output, &ConfigMetadata::default())
150            .unwrap();
151        assert_eq!(
152            result,
153            "test.md:1:1: [WARN] This is a warning\n\n🔍 1 source linted\n🟡 Found 1 warning\n"
154        );
155    }
156
157    #[test]
158    fn test_simple_formatter_warning_and_error() {
159        let file_path = "test.md".to_string();
160        let error1 = LintError::from_raw_location()
161            .rule("MockRule")
162            .level(LintLevel::Error)
163            .message("This is an error")
164            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
165            .call();
166        let error2 = LintError::from_raw_location()
167            .rule("MockRule")
168            .level(LintLevel::Warning)
169            .message("This is a warning")
170            .location(DenormalizedLocation::dummy(14, 46, 3, 0, 4, 2))
171            .call();
172        let output = LintOutput {
173            file_path,
174            errors: vec![error1, error2],
175        };
176        let output = vec![output];
177
178        let formatter = SimpleFormatter;
179        let result = formatter
180            .format(&output, &ConfigMetadata::default())
181            .unwrap();
182        assert_eq!(
183            result,
184            "test.md:1:1: [ERROR] This is an error\ntest.md:4:1: [WARN] This is a warning\n\n🔍 1 source linted\n🔴 Found 1 error and 1 warning\n"
185        );
186    }
187
188    #[test]
189    fn test_simple_formatter_no_errors() {
190        let file_path = "test.md".to_string();
191        let output = LintOutput {
192            file_path,
193            errors: vec![],
194        };
195        let output = vec![output];
196
197        let formatter = SimpleFormatter;
198        let result = formatter
199            .format(&output, &ConfigMetadata::default())
200            .unwrap();
201        assert_eq!(
202            result,
203            "🔍 1 source linted\n🟢 No errors or warnings found\n"
204        );
205    }
206
207    #[test]
208    fn test_simple_formatter_multiple_errors() {
209        let file_path = "test.md".to_string();
210        let error_1 = LintError::from_raw_location()
211            .rule("MockRule")
212            .level(LintLevel::Error)
213            .message("This is an error")
214            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
215            .call();
216        let error_2 = LintError::from_raw_location()
217            .rule("MockRule")
218            .level(LintLevel::Error)
219            .message("This is another error")
220            .location(DenormalizedLocation::dummy(14, 46, 3, 0, 4, 2))
221            .call();
222
223        let output = LintOutput {
224            file_path,
225            errors: vec![error_1, error_2],
226        };
227        let output = vec![output];
228
229        let formatter = SimpleFormatter;
230        let result = formatter
231            .format(&output, &ConfigMetadata::default())
232            .unwrap();
233        assert_eq!(
234            result,
235            "test.md:1:1: [ERROR] This is an error\ntest.md:4:1: [ERROR] This is another error\n\n🔍 1 source linted\n🔴 Found 2 errors\n"
236        );
237    }
238
239    #[test]
240    fn test_simple_formatter_multiple_files() {
241        let file_path_1 = "test.md".to_string();
242        let error_1 = LintError::from_raw_location()
243            .rule("MockRule")
244            .level(LintLevel::Error)
245            .message("This is an error")
246            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
247            .call();
248        let error_2 = LintError::from_raw_location()
249            .rule("MockRule")
250            .level(LintLevel::Error)
251            .message("This is another error")
252            .location(DenormalizedLocation::dummy(14, 46, 3, 0, 4, 2))
253            .call();
254
255        let output_1 = LintOutput {
256            file_path: file_path_1,
257            errors: vec![error_1, error_2],
258        };
259
260        let file_path_2 = "test2.md".to_string();
261        let error_3 = LintError::from_raw_location()
262            .rule("MockRule")
263            .level(LintLevel::Error)
264            .message("This is an error")
265            .location(DenormalizedLocation::dummy(0, 7, 0, 0, 1, 0))
266            .call();
267        let error_4 = LintError::from_raw_location()
268            .rule("MockRule")
269            .level(LintLevel::Error)
270            .message("This is another error")
271            .location(DenormalizedLocation::dummy(14, 46, 3, 0, 4, 2))
272            .call();
273
274        let output_2 = LintOutput {
275            file_path: file_path_2,
276            errors: vec![error_3, error_4],
277        };
278
279        let output = vec![output_1, output_2];
280
281        let formatter = SimpleFormatter;
282        let result = formatter
283            .format(&output, &ConfigMetadata::default())
284            .unwrap();
285        assert_eq!(
286            result,
287            "test.md:1:1: [ERROR] This is an error\ntest.md:4:1: [ERROR] This is another error\ntest2.md:1:1: [ERROR] This is an error\ntest2.md:4:1: [ERROR] This is another error\n\n🔍 2 sources linted\n🔴 Found 4 errors\n"
288        );
289    }
290}