1use crate::format::Formatter;
2use crate::lint::LintResult;
3
4pub struct DefaultFormatter {
5 use_color: bool,
6 show_context: bool,
8}
9
10impl DefaultFormatter {
11 pub fn new(use_color: bool) -> Self {
12 Self {
13 use_color,
14 show_context: true,
15 }
16 }
17
18 pub fn without_context(use_color: bool) -> Self {
19 Self {
20 use_color,
21 show_context: false,
22 }
23 }
24
25 fn colorize(&self, text: &str, color_code: &str) -> String {
26 if self.use_color {
27 format!("\x1b[{}m{}\x1b[0m", color_code, text)
28 } else {
29 text.to_string()
30 }
31 }
32
33 fn red(&self, text: &str) -> String {
34 self.colorize(text, "31")
35 }
36
37 fn yellow(&self, text: &str) -> String {
38 self.colorize(text, "33")
39 }
40
41 fn gray(&self, text: &str) -> String {
42 self.colorize(text, "90")
43 }
44}
45
46impl Formatter for DefaultFormatter {
47 fn format(&self, result: &LintResult) -> String {
48 let mut output = String::new();
49
50 for file_result in &result.file_results {
52 if file_result.violations.is_empty() {
53 continue;
54 }
55
56 let path_display = file_result.path.display();
58 output.push_str(&format!("{}\n", self.yellow(&path_display.to_string())));
59
60 for violation in &file_result.violations {
62 let location = if let Some(col) = violation.column {
63 format!("{}:{}", violation.line, col)
64 } else {
65 format!("{}", violation.line)
66 };
67
68 output.push_str(&format!(
69 " {}: {} {}\n",
70 self.gray(&location),
71 self.red(&violation.rule),
72 violation.message
73 ));
74
75 if self.show_context {
77 let line_idx = violation.line.saturating_sub(1);
78 if let Some(src) = file_result.source_lines.get(line_idx) {
79 let src_trimmed = src.trim_end();
80 output.push_str(&format!(" | {}\n", src_trimmed));
81 if let Some(col) = violation.column {
82 let spaces = " ".repeat(col.saturating_sub(1));
84 output.push_str(&format!(" | {}{}\n", spaces, self.red("^")));
85 }
86 }
87 }
88 }
89
90 output.push('\n');
91 }
92
93 let files_with_errors = result.file_results.len();
95 let total = result.total_files_checked;
96 if result.total_errors == 0 {
97 let msg = format!("Checked {} file(s), no errors found.", total);
98 output.push_str(&format!("{}\n", self.gray(&msg)));
99 } else {
100 let summary = format!(
101 "Found {} error(s) in {} file(s) ({} checked)",
102 result.total_errors, files_with_errors, total
103 );
104 output.push_str(&format!("{}\n", self.red(&summary)));
105 }
106
107 output
108 }
109
110 fn supports_color(&self) -> bool {
111 self.use_color
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::types::Violation;
119 use std::path::PathBuf;
120
121 #[test]
122 fn test_no_errors() {
123 let formatter = DefaultFormatter::new(false);
124 let result = LintResult::new();
125 let output = formatter.format(&result);
126
127 assert!(output.contains("no errors found"));
128 }
129
130 fn make_violation(line: usize, col: Option<usize>, rule: &str, msg: &str) -> Violation {
131 Violation {
132 line,
133 column: col,
134 rule: rule.to_string(),
135 message: msg.to_string(),
136 fix: None,
137 }
138 }
139
140 #[test]
141 fn test_single_violation() {
142 let formatter = DefaultFormatter::without_context(false);
143 let mut result = LintResult::new();
144 result.add_file_result(
145 PathBuf::from("test.md"),
146 vec![make_violation(
147 5,
148 Some(10),
149 "MD001",
150 "Heading levels should increment by one",
151 )],
152 vec![],
153 );
154 let output = formatter.format(&result);
155 assert!(output.contains("test.md"));
156 assert!(output.contains("5:10"));
157 assert!(output.contains("MD001"));
158 assert!(output.contains("Heading levels"));
159 assert!(output.contains("Found 1 error(s)"));
160 }
161
162 #[test]
163 fn test_multiple_violations() {
164 let formatter = DefaultFormatter::without_context(false);
165 let mut result = LintResult::new();
166 result.add_file_result(
167 PathBuf::from("file1.md"),
168 vec![
169 make_violation(1, Some(1), "MD001", "First error"),
170 make_violation(10, None, "MD002", "Second error"),
171 ],
172 vec![],
173 );
174 result.add_file_result(
175 PathBuf::from("file2.md"),
176 vec![make_violation(3, Some(5), "MD003", "Third error")],
177 vec![],
178 );
179 let output = formatter.format(&result);
180 assert!(output.contains("file1.md"));
181 assert!(output.contains("file2.md"));
182 assert!(output.contains("Found 3 error(s) in 2 file(s)"));
183 }
184
185 #[test]
186 fn test_with_color() {
187 let formatter = DefaultFormatter::new(true);
188 let mut result = LintResult::new();
189 result.add_file_result(
190 PathBuf::from("test.md"),
191 vec![make_violation(5, Some(10), "MD001", "Test error")],
192 vec![],
193 );
194 let output = formatter.format(&result);
195 assert!(output.contains("\x1b["));
196 }
197
198 #[test]
199 fn test_source_snippet_shown() {
200 let formatter = DefaultFormatter::new(false);
201 let mut result = LintResult::new();
202 let source_lines = vec![
203 "# Good Heading".to_string(),
204 "#Bad heading".to_string(),
205 "More text".to_string(),
206 ];
207 result.add_file_result(
208 PathBuf::from("test.md"),
209 vec![make_violation(2, Some(1), "MD018", "No space after hash")],
210 source_lines,
211 );
212 let output = formatter.format(&result);
213 assert!(
214 output.contains("#Bad heading"),
215 "snippet should appear in output"
216 );
217 assert!(output.contains('^'), "caret should appear under the column");
218 }
219}