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> = content.lines().collect();
120 let mut output = String::new();
121
122 for (i, warning) in warnings.iter().enumerate() {
123 if i > 0 {
124 output.push('\n');
125 }
126
127 let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
128 let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
129
130 if self.use_colors {
132 output.push_str(&format!(
133 "{} {}{}",
134 rule_name.red().bold(),
135 warning.message,
136 fix_indicator.green()
137 ));
138 } else {
139 output.push_str(&format!("{rule_name} {}{fix_indicator}", warning.message));
140 }
141 output.push('\n');
142
143 if self.use_colors {
145 output.push_str(&format!(
146 " {} {}:{}:{}\n",
147 "-->".blue().bold(),
148 file_path,
149 warning.line,
150 warning.column,
151 ));
152 } else {
153 output.push_str(&format!(" --> {}:{}:{}\n", file_path, warning.line, warning.column,));
154 }
155
156 self.render_source_context(&mut output, warning, &lines);
158 }
159
160 if output.ends_with('\n') {
162 output.pop();
163 }
164
165 output
166 }
167
168 fn use_colors(&self) -> bool {
169 self.use_colors
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::rule::{Fix, Severity};
177
178 fn make_warning(line: usize, column: usize, end_column: usize, rule: &str, message: &str) -> LintWarning {
179 LintWarning {
180 line,
181 column,
182 end_line: line,
183 end_column,
184 rule_name: Some(rule.to_string()),
185 message: message.to_string(),
186 severity: Severity::Warning,
187 fix: None,
188 }
189 }
190
191 #[test]
192 fn test_full_formatter_without_content_falls_back() {
193 let formatter = FullFormatter::without_colors();
194 let warnings = vec![make_warning(1, 1, 5, "MD001", "Heading increment")];
195 let output = formatter.format_warnings(&warnings, "test.md");
196 assert!(output.contains("test.md:1:1:"));
197 assert!(output.contains("MD001"));
198 assert!(output.contains("Heading increment"));
199 }
200
201 #[test]
202 fn test_full_formatter_with_content() {
203 let formatter = FullFormatter::without_colors();
204 let content = "# Hello\n\nThis is a test line that is long\n";
205 let warnings = vec![make_warning(3, 1, 33, "MD013", "Line length")];
206 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
207
208 assert!(output.contains("MD013 Line length"));
209 assert!(output.contains(" --> test.md:3:1"));
210 assert!(output.contains("This is a test line that is long"));
211 assert!(output.contains("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"));
212 }
213
214 #[test]
215 fn test_full_formatter_with_fix_indicator() {
216 let formatter = FullFormatter::without_colors();
217 let content = "# Hello\n";
218 let warnings = vec![LintWarning {
219 line: 1,
220 column: 1,
221 end_line: 1,
222 end_column: 8,
223 rule_name: Some("MD022".to_string()),
224 message: "Headings should be surrounded by blank lines".to_string(),
225 severity: Severity::Warning,
226 fix: Some(Fix {
227 range: 0..8,
228 replacement: "\n# Hello\n".to_string(),
229 }),
230 }];
231 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
232 assert!(output.contains("[*]"));
233 }
234
235 #[test]
236 fn test_full_formatter_multiple_warnings() {
237 let formatter = FullFormatter::without_colors();
238 let content = "# Hello\n\nSecond line\n\nThird line\n";
239 let warnings = vec![
240 make_warning(1, 1, 8, "MD001", "First issue"),
241 make_warning(3, 1, 12, "MD013", "Second issue"),
242 ];
243 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
244
245 assert!(output.contains("MD001 First issue"));
246 assert!(output.contains("MD013 Second issue"));
247 assert!(output.contains("# Hello"));
248 assert!(output.contains("Second line"));
249 }
250
251 #[test]
252 fn test_full_formatter_column_offset() {
253 let formatter = FullFormatter::without_colors();
254 let content = "Some text with issue here\n";
255 let warnings = vec![make_warning(1, 16, 21, "MD001", "Problem here")];
256 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
257
258 assert!(output.contains(" ^^^^^"));
260 }
261
262 #[test]
263 fn test_full_formatter_empty_warnings() {
264 let formatter = FullFormatter::without_colors();
265 let content = "# Hello\n";
266 let output = formatter.format_warnings_with_content(&[], "test.md", content);
267 assert!(output.is_empty());
268 }
269
270 #[test]
271 fn test_full_formatter_line_out_of_range() {
272 let formatter = FullFormatter::without_colors();
273 let content = "Only one line\n";
274 let warnings = vec![make_warning(5, 1, 5, "MD001", "Out of range")];
275 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
276
277 assert!(output.contains("MD001 Out of range"));
279 assert!(output.contains(" --> test.md:5:1"));
280 }
281
282 #[test]
283 fn test_full_formatter_no_rule_name() {
284 let formatter = FullFormatter::without_colors();
285 let content = "# Hello\n";
286 let warnings = vec![LintWarning {
287 line: 1,
288 column: 1,
289 end_line: 1,
290 end_column: 5,
291 rule_name: None,
292 message: "Generic warning".to_string(),
293 severity: Severity::Warning,
294 fix: None,
295 }];
296 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
297 assert!(output.contains("unknown Generic warning"));
298 }
299
300 #[test]
301 fn test_full_formatter_single_char_caret() {
302 let formatter = FullFormatter::without_colors();
303 let content = "Hello world\n";
304 let warnings = vec![make_warning(1, 5, 5, "MD001", "Single char")];
306 let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
307 assert!(output.contains(" ^"));
308 }
309
310 #[test]
311 fn test_full_formatter_gutter_width_for_large_line_numbers() {
312 let formatter = FullFormatter::without_colors();
313 let mut content = String::new();
314 for i in 1..=150 {
315 content.push_str(&format!("Line {i}\n"));
316 }
317 let warnings = vec![make_warning(142, 1, 9, "MD001", "At line 142")];
318 let output = formatter.format_warnings_with_content(&warnings, "test.md", &content);
319
320 assert!(output.contains("142 | Line 142"));
322 }
323
324 #[test]
325 fn test_full_formatter_empty_content_falls_back() {
326 let formatter = FullFormatter::without_colors();
327 let warnings = vec![make_warning(1, 1, 5, "MD001", "Test")];
328 let output = formatter.format_warnings_with_content(&warnings, "test.md", "");
329 assert!(output.contains("test.md:1:1:"));
331 }
332}