rumdl_lib/output/formatters/
full.rs1use crate::output::OutputFormatter;
4use crate::rule::LintWarning;
5use colored::*;
6
7pub struct FullFormatter {
9 use_colors: bool,
10}
11
12impl Default for FullFormatter {
13 fn default() -> Self {
14 Self { use_colors: true }
15 }
16}
17
18impl FullFormatter {
19 pub fn new() -> Self {
20 Self::default()
21 }
22
23 pub fn without_colors() -> Self {
24 Self { use_colors: false }
25 }
26
27 fn render_source_context(&self, output: &mut String, warning: &LintWarning, lines: &[&str]) {
29 let line_idx = warning.line.saturating_sub(1);
30 if line_idx >= lines.len() {
31 return;
32 }
33
34 let source_line = lines[line_idx];
35 let line_num = warning.line;
36 let gutter_width = line_num.to_string().len().max(2);
37 let empty_gutter = " ".repeat(gutter_width);
38
39 if self.use_colors {
41 output.push_str(&format!("{empty_gutter} {}\n", "|".blue().bold()));
42 } else {
43 output.push_str(&format!("{empty_gutter} |\n"));
44 }
45
46 if self.use_colors {
48 output.push_str(&format!(
49 "{:>width$} {} {}\n",
50 line_num.to_string().blue().bold(),
51 "|".blue().bold(),
52 source_line,
53 width = gutter_width,
54 ));
55 } else {
56 output.push_str(&format!("{line_num:>gutter_width$} | {source_line}\n"));
57 }
58
59 let col = warning.column.saturating_sub(1);
61 let end_col = if warning.end_column > warning.column {
62 warning.end_column.saturating_sub(1)
63 } else {
64 col + 1
65 };
66 let caret_len = end_col.saturating_sub(col).max(1);
67 let padding = " ".repeat(col);
68 let carets = "^".repeat(caret_len);
69
70 if self.use_colors {
71 output.push_str(&format!(
72 "{empty_gutter} {} {padding}{}\n",
73 "|".blue().bold(),
74 carets.yellow().bold(),
75 ));
76 } else {
77 output.push_str(&format!("{empty_gutter} | {padding}{carets}\n"));
78 }
79
80 if self.use_colors {
82 output.push_str(&format!("{empty_gutter} {}\n", "|".blue().bold()));
83 } else {
84 output.push_str(&format!("{empty_gutter} |\n"));
85 }
86 }
87}
88
89impl OutputFormatter for FullFormatter {
90 fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
91 let mut output = String::new();
93
94 for warning in warnings {
95 let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
96 let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
97
98 let line = format!(
99 "{}:{}:{}: [{}] {}{}",
100 file_path, warning.line, warning.column, rule_name, warning.message, fix_indicator,
101 );
102
103 output.push_str(&line);
104 output.push('\n');
105 }
106
107 if output.ends_with('\n') {
108 output.pop();
109 }
110
111 output
112 }
113
114 fn format_warnings_with_content(&self, warnings: &[LintWarning], file_path: &str, content: &str) -> String {
115 if content.is_empty() {
116 return self.format_warnings(warnings, file_path);
117 }
118
119 let lines: Vec<&str> = crate::utils::bom::strip_bom(content).lines().collect();
121 let mut output = String::new();
122
123 for (i, warning) in warnings.iter().enumerate() {
124 if i > 0 {
125 output.push('\n');
126 }
127
128 let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
129 let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
130
131 if self.use_colors {
133 output.push_str(&format!(
134 "{} {}{}",
135 rule_name.red().bold(),
136 warning.message,
137 fix_indicator.green()
138 ));
139 } else {
140 output.push_str(&format!("{rule_name} {}{fix_indicator}", warning.message));
141 }
142 output.push('\n');
143
144 if self.use_colors {
146 output.push_str(&format!(
147 " {} {}:{}:{}\n",
148 "-->".blue().bold(),
149 file_path,
150 warning.line,
151 warning.column,
152 ));
153 } else {
154 output.push_str(&format!(" --> {}:{}:{}\n", file_path, warning.line, warning.column));
155 }
156
157 self.render_source_context(&mut output, warning, &lines);
159 }
160
161 if output.ends_with('\n') {
163 output.pop();
164 }
165
166 output
167 }
168
169 fn use_colors(&self) -> bool {
170 self.use_colors
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::rule::{Fix, Severity};
178
179 fn make_warning(line: usize, column: usize, end_column: usize, rule: &str, message: &str) -> LintWarning {
180 LintWarning {
181 line,
182 column,
183 end_line: line,
184 end_column,
185 rule_name: Some(rule.to_string()),
186 message: message.to_string(),
187 severity: Severity::Warning,
188 fix: None,
189 }
190 }
191
192 #[test]
193 fn test_full_formatter_without_content_falls_back() {
194 let formatter = FullFormatter::without_colors();
195 let warnings = vec![make_warning(1, 1, 5, "MD001", "Heading increment")];
196 let output = formatter.format_warnings(&warnings, "test.md");
197 assert!(output.contains("test.md:1:1:"));
198 assert!(output.contains("MD001"));
199 assert!(output.contains("Heading increment"));
200 }
201
202 #[test]
203 fn test_full_formatter_with_content() {
204 let formatter = FullFormatter::without_colors();
205 let content = "# Hello\n\nThis is a test line that is long\n";
206 let warnings = vec![make_warning(3, 1, 33, "MD013", "Line length")];
207 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
208
209 assert!(output.contains("MD013 Line length"));
210 assert!(output.contains(" --> test.md:3:1"));
211 assert!(output.contains("This is a test line that is long"));
212 assert!(output.contains("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"));
213 }
214
215 #[test]
216 fn test_full_formatter_with_fix_indicator() {
217 let formatter = FullFormatter::without_colors();
218 let content = "# Hello\n";
219 let warnings = vec![LintWarning {
220 line: 1,
221 column: 1,
222 end_line: 1,
223 end_column: 8,
224 rule_name: Some("MD022".to_string()),
225 message: "Headings should be surrounded by blank lines".to_string(),
226 severity: Severity::Warning,
227 fix: Some(Fix::new(0..8, "\n# Hello\n".to_string())),
228 }];
229 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
230 assert!(output.contains("[*]"));
231 }
232
233 #[test]
234 fn test_full_formatter_multiple_warnings() {
235 let formatter = FullFormatter::without_colors();
236 let content = "# Hello\n\nSecond line\n\nThird line\n";
237 let warnings = vec![
238 make_warning(1, 1, 8, "MD001", "First issue"),
239 make_warning(3, 1, 12, "MD013", "Second issue"),
240 ];
241 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
242
243 assert!(output.contains("MD001 First issue"));
244 assert!(output.contains("MD013 Second issue"));
245 assert!(output.contains("# Hello"));
246 assert!(output.contains("Second line"));
247 }
248
249 #[test]
250 fn test_full_formatter_column_offset() {
251 let formatter = FullFormatter::without_colors();
252 let content = "Some text with issue here\n";
253 let warnings = vec![make_warning(1, 16, 21, "MD001", "Problem here")];
254 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
255
256 assert!(output.contains(" ^^^^^"));
258 }
259
260 #[test]
261 fn test_full_formatter_empty_warnings() {
262 let formatter = FullFormatter::without_colors();
263 let content = "# Hello\n";
264 let output = formatter.format_warnings_with_content(&[], "test.md", content);
265 assert!(output.is_empty());
266 }
267
268 #[test]
269 fn test_full_formatter_line_out_of_range() {
270 let formatter = FullFormatter::without_colors();
271 let content = "Only one line\n";
272 let warnings = vec![make_warning(5, 1, 5, "MD001", "Out of range")];
273 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
274
275 assert!(output.contains("MD001 Out of range"));
277 assert!(output.contains(" --> test.md:5:1"));
278 }
279
280 #[test]
281 fn test_full_formatter_no_rule_name() {
282 let formatter = FullFormatter::without_colors();
283 let content = "# Hello\n";
284 let warnings = vec![LintWarning {
285 line: 1,
286 column: 1,
287 end_line: 1,
288 end_column: 5,
289 rule_name: None,
290 message: "Generic warning".to_string(),
291 severity: Severity::Warning,
292 fix: None,
293 }];
294 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
295 assert!(output.contains("unknown Generic warning"));
296 }
297
298 #[test]
299 fn test_full_formatter_single_char_caret() {
300 let formatter = FullFormatter::without_colors();
301 let content = "Hello world\n";
302 let warnings = vec![make_warning(1, 5, 5, "MD001", "Single char")];
304 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
305 assert!(output.contains(" ^"));
306 }
307
308 #[test]
309 fn test_full_formatter_gutter_width_for_large_line_numbers() {
310 let formatter = FullFormatter::without_colors();
311 let mut content = String::new();
312 for i in 1..=150 {
313 content.push_str(&format!("Line {i}\n"));
314 }
315 let warnings = vec![make_warning(142, 1, 9, "MD001", "At line 142")];
316 let output = formatter.format_warnings_with_content(&warnings, "test.md", &content);
317
318 assert!(output.contains("142 | Line 142"));
320 }
321
322 #[test]
323 fn test_full_formatter_empty_content_falls_back() {
324 let formatter = FullFormatter::without_colors();
325 let warnings = vec![make_warning(1, 1, 5, "MD001", "Test")];
326 let output = formatter.format_warnings_with_content(&warnings, "test.md", "");
327 assert!(output.contains("test.md:1:1:"));
329 }
330}