use crate::output::OutputFormatter;
use crate::rule::LintWarning;
use colored::*;
pub struct FullFormatter {
use_colors: bool,
}
impl Default for FullFormatter {
fn default() -> Self {
Self { use_colors: true }
}
}
impl FullFormatter {
pub fn new() -> Self {
Self::default()
}
pub fn without_colors() -> Self {
Self { use_colors: false }
}
fn render_source_context(&self, output: &mut String, warning: &LintWarning, lines: &[&str]) {
let line_idx = warning.line.saturating_sub(1);
if line_idx >= lines.len() {
return;
}
let source_line = lines[line_idx];
let line_num = warning.line;
let gutter_width = line_num.to_string().len().max(2);
let empty_gutter = " ".repeat(gutter_width);
if self.use_colors {
output.push_str(&format!("{empty_gutter} {}\n", "|".blue().bold()));
} else {
output.push_str(&format!("{empty_gutter} |\n"));
}
if self.use_colors {
output.push_str(&format!(
"{:>width$} {} {}\n",
line_num.to_string().blue().bold(),
"|".blue().bold(),
source_line,
width = gutter_width,
));
} else {
output.push_str(&format!("{line_num:>gutter_width$} | {source_line}\n"));
}
let col = warning.column.saturating_sub(1);
let end_col = if warning.end_column > warning.column {
warning.end_column.saturating_sub(1)
} else {
col + 1
};
let caret_len = end_col.saturating_sub(col).max(1);
let padding = " ".repeat(col);
let carets = "^".repeat(caret_len);
if self.use_colors {
output.push_str(&format!(
"{empty_gutter} {} {padding}{}\n",
"|".blue().bold(),
carets.yellow().bold(),
));
} else {
output.push_str(&format!("{empty_gutter} | {padding}{carets}\n"));
}
if self.use_colors {
output.push_str(&format!("{empty_gutter} {}\n", "|".blue().bold()));
} else {
output.push_str(&format!("{empty_gutter} |\n"));
}
}
}
impl OutputFormatter for FullFormatter {
fn format_warnings(&self, warnings: &[LintWarning], file_path: &str) -> String {
let mut output = String::new();
for warning in warnings {
let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
let line = format!(
"{}:{}:{}: [{}] {}{}",
file_path, warning.line, warning.column, rule_name, warning.message, fix_indicator,
);
output.push_str(&line);
output.push('\n');
}
if output.ends_with('\n') {
output.pop();
}
output
}
fn format_warnings_with_content(&self, warnings: &[LintWarning], file_path: &str, content: &str) -> String {
if content.is_empty() {
return self.format_warnings(warnings, file_path);
}
let lines: Vec<&str> = content.lines().collect();
let mut output = String::new();
for (i, warning) in warnings.iter().enumerate() {
if i > 0 {
output.push('\n');
}
let rule_name = warning.rule_name.as_deref().unwrap_or("unknown");
let fix_indicator = if warning.fix.is_some() { " [*]" } else { "" };
if self.use_colors {
output.push_str(&format!(
"{} {}{}",
rule_name.red().bold(),
warning.message,
fix_indicator.green()
));
} else {
output.push_str(&format!("{rule_name} {}{fix_indicator}", warning.message));
}
output.push('\n');
if self.use_colors {
output.push_str(&format!(
" {} {}:{}:{}\n",
"-->".blue().bold(),
file_path,
warning.line,
warning.column,
));
} else {
output.push_str(&format!(" --> {}:{}:{}\n", file_path, warning.line, warning.column,));
}
self.render_source_context(&mut output, warning, &lines);
}
if output.ends_with('\n') {
output.pop();
}
output
}
fn use_colors(&self) -> bool {
self.use_colors
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::{Fix, Severity};
fn make_warning(line: usize, column: usize, end_column: usize, rule: &str, message: &str) -> LintWarning {
LintWarning {
line,
column,
end_line: line,
end_column,
rule_name: Some(rule.to_string()),
message: message.to_string(),
severity: Severity::Warning,
fix: None,
}
}
#[test]
fn test_full_formatter_without_content_falls_back() {
let formatter = FullFormatter::without_colors();
let warnings = vec![make_warning(1, 1, 5, "MD001", "Heading increment")];
let output = formatter.format_warnings(&warnings, "test.md");
assert!(output.contains("test.md:1:1:"));
assert!(output.contains("MD001"));
assert!(output.contains("Heading increment"));
}
#[test]
fn test_full_formatter_with_content() {
let formatter = FullFormatter::without_colors();
let content = "# Hello\n\nThis is a test line that is long\n";
let warnings = vec![make_warning(3, 1, 33, "MD013", "Line length")];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains("MD013 Line length"));
assert!(output.contains(" --> test.md:3:1"));
assert!(output.contains("This is a test line that is long"));
assert!(output.contains("^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"));
}
#[test]
fn test_full_formatter_with_fix_indicator() {
let formatter = FullFormatter::without_colors();
let content = "# Hello\n";
let warnings = vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 8,
rule_name: Some("MD022".to_string()),
message: "Headings should be surrounded by blank lines".to_string(),
severity: Severity::Warning,
fix: Some(Fix {
range: 0..8,
replacement: "\n# Hello\n".to_string(),
}),
}];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains("[*]"));
}
#[test]
fn test_full_formatter_multiple_warnings() {
let formatter = FullFormatter::without_colors();
let content = "# Hello\n\nSecond line\n\nThird line\n";
let warnings = vec![
make_warning(1, 1, 8, "MD001", "First issue"),
make_warning(3, 1, 12, "MD013", "Second issue"),
];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains("MD001 First issue"));
assert!(output.contains("MD013 Second issue"));
assert!(output.contains("# Hello"));
assert!(output.contains("Second line"));
}
#[test]
fn test_full_formatter_column_offset() {
let formatter = FullFormatter::without_colors();
let content = "Some text with issue here\n";
let warnings = vec![make_warning(1, 16, 21, "MD001", "Problem here")];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains(" ^^^^^"));
}
#[test]
fn test_full_formatter_empty_warnings() {
let formatter = FullFormatter::without_colors();
let content = "# Hello\n";
let output = formatter.format_warnings_with_content(&[], "test.md", content);
assert!(output.is_empty());
}
#[test]
fn test_full_formatter_line_out_of_range() {
let formatter = FullFormatter::without_colors();
let content = "Only one line\n";
let warnings = vec![make_warning(5, 1, 5, "MD001", "Out of range")];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains("MD001 Out of range"));
assert!(output.contains(" --> test.md:5:1"));
}
#[test]
fn test_full_formatter_no_rule_name() {
let formatter = FullFormatter::without_colors();
let content = "# Hello\n";
let warnings = vec![LintWarning {
line: 1,
column: 1,
end_line: 1,
end_column: 5,
rule_name: None,
message: "Generic warning".to_string(),
severity: Severity::Warning,
fix: None,
}];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains("unknown Generic warning"));
}
#[test]
fn test_full_formatter_single_char_caret() {
let formatter = FullFormatter::without_colors();
let content = "Hello world\n";
let warnings = vec![make_warning(1, 5, 5, "MD001", "Single char")];
let output = formatter.format_warnings_with_content(&warnings, "test.md", content);
assert!(output.contains(" ^"));
}
#[test]
fn test_full_formatter_gutter_width_for_large_line_numbers() {
let formatter = FullFormatter::without_colors();
let mut content = String::new();
for i in 1..=150 {
content.push_str(&format!("Line {i}\n"));
}
let warnings = vec![make_warning(142, 1, 9, "MD001", "At line 142")];
let output = formatter.format_warnings_with_content(&warnings, "test.md", &content);
assert!(output.contains("142 | Line 142"));
}
#[test]
fn test_full_formatter_empty_content_falls_back() {
let formatter = FullFormatter::without_colors();
let warnings = vec![make_warning(1, 1, 5, "MD001", "Test")];
let output = formatter.format_warnings_with_content(&warnings, "test.md", "");
assert!(output.contains("test.md:1:1:"));
}
}