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