use crate::inline_config::InlineConfig;
use crate::rule::{Fix, LintWarning};
use crate::utils::ensure_consistent_line_endings;
pub fn filter_warnings_by_inline_config(
warnings: Vec<LintWarning>,
inline_config: &InlineConfig,
rule_name: &str,
) -> Vec<LintWarning> {
let base_rule_name = if let Some(dash_pos) = rule_name.find('-') {
let prefix = &rule_name[..dash_pos];
if prefix.starts_with("MD") { prefix } else { rule_name }
} else {
rule_name
};
warnings
.into_iter()
.filter(|w| {
let end = if w.end_line >= w.line { w.end_line } else { w.line };
!(w.line..=end).any(|line| inline_config.is_rule_disabled(base_rule_name, line))
})
.collect()
}
pub fn apply_warning_fixes(content: &str, warnings: &[LintWarning]) -> Result<String, String> {
let mut fixes: Vec<(usize, &Fix)> = warnings
.iter()
.enumerate()
.filter_map(|(i, w)| w.fix.as_ref().map(|fix| (i, fix)))
.collect();
fixes.sort_by(|(_, fix_a), (_, fix_b)| {
let range_cmp = fix_a.range.start.cmp(&fix_b.range.start);
if range_cmp != std::cmp::Ordering::Equal {
return range_cmp;
}
fix_a.range.end.cmp(&fix_b.range.end)
});
let mut deduplicated = Vec::new();
let mut i = 0;
while i < fixes.len() {
let (idx, current_fix) = fixes[i];
deduplicated.push((idx, current_fix));
while i + 1 < fixes.len() {
let (_, next_fix) = fixes[i + 1];
if current_fix.range == next_fix.range && current_fix.replacement == next_fix.replacement {
i += 1; } else {
break;
}
}
i += 1;
}
let mut fixes = deduplicated;
fixes.sort_by(|(idx_a, fix_a), (idx_b, fix_b)| {
let range_cmp = fix_b.range.start.cmp(&fix_a.range.start);
if range_cmp != std::cmp::Ordering::Equal {
return range_cmp;
}
let end_cmp = fix_b.range.end.cmp(&fix_a.range.end);
if end_cmp != std::cmp::Ordering::Equal {
return end_cmp;
}
idx_a.cmp(idx_b)
});
let mut result = content.to_string();
for (_, fix) in fixes {
if fix.range.end > result.len() {
return Err(format!(
"Fix range end {} exceeds content length {}",
fix.range.end,
result.len()
));
}
if fix.range.start > fix.range.end {
return Err(format!(
"Invalid fix range: start {} > end {}",
fix.range.start, fix.range.end
));
}
result.replace_range(fix.range.clone(), &fix.replacement);
}
Ok(ensure_consistent_line_endings(content, &result))
}
pub fn warning_fix_to_edit(content: &str, warning: &LintWarning) -> Result<(usize, usize, String), String> {
if let Some(fix) = &warning.fix {
if fix.range.end > content.len() {
return Err(format!(
"Fix range end {} exceeds content length {}",
fix.range.end,
content.len()
));
}
Ok((fix.range.start, fix.range.end, fix.replacement.clone()))
} else {
Err("Warning has no fix".to_string())
}
}
pub fn validate_fix_range(content: &str, fix: &Fix) -> Result<(), String> {
if fix.range.start > content.len() {
return Err(format!(
"Fix range start {} exceeds content length {}",
fix.range.start,
content.len()
));
}
if fix.range.end > content.len() {
return Err(format!(
"Fix range end {} exceeds content length {}",
fix.range.end,
content.len()
));
}
if fix.range.start > fix.range.end {
return Err(format!(
"Invalid fix range: start {} > end {}",
fix.range.start, fix.range.end
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rule::{Fix, LintWarning, Severity};
#[test]
fn test_apply_single_fix() {
let content = "1. Multiple spaces";
let warning = LintWarning {
message: "Too many spaces".to_string(),
line: 1,
column: 3,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: Some(Fix {
range: 2..4, replacement: " ".to_string(), }),
rule_name: Some("MD030".to_string()),
};
let result = apply_warning_fixes(content, &[warning]).unwrap();
assert_eq!(result, "1. Multiple spaces");
}
#[test]
fn test_apply_multiple_fixes() {
let content = "1. First\n* Second";
let warnings = vec![
LintWarning {
message: "Too many spaces".to_string(),
line: 1,
column: 3,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: Some(Fix {
range: 2..4, replacement: " ".to_string(),
}),
rule_name: Some("MD030".to_string()),
},
LintWarning {
message: "Too many spaces".to_string(),
line: 2,
column: 2,
end_line: 2,
end_column: 5,
severity: Severity::Warning,
fix: Some(Fix {
range: 11..14, replacement: " ".to_string(),
}),
rule_name: Some("MD030".to_string()),
},
];
let result = apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(result, "1. First\n* Second");
}
#[test]
fn test_apply_non_overlapping_fixes() {
let content = "Test multiple spaces";
let warnings = vec![
LintWarning {
message: "Too many spaces".to_string(),
line: 1,
column: 5,
end_line: 1,
end_column: 7,
severity: Severity::Warning,
fix: Some(Fix {
range: 4..6, replacement: " ".to_string(),
}),
rule_name: Some("MD009".to_string()),
},
LintWarning {
message: "Too many spaces".to_string(),
line: 1,
column: 15,
end_line: 1,
end_column: 19,
severity: Severity::Warning,
fix: Some(Fix {
range: 14..18, replacement: " ".to_string(),
}),
rule_name: Some("MD009".to_string()),
},
];
let result = apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(result, "Test multiple spaces");
}
#[test]
fn test_apply_duplicate_fixes() {
let content = "Test content";
let warnings = vec![
LintWarning {
message: "Fix 1".to_string(),
line: 1,
column: 5,
end_line: 1,
end_column: 7,
severity: Severity::Warning,
fix: Some(Fix {
range: 4..6,
replacement: " ".to_string(),
}),
rule_name: Some("MD009".to_string()),
},
LintWarning {
message: "Fix 2 (duplicate)".to_string(),
line: 1,
column: 5,
end_line: 1,
end_column: 7,
severity: Severity::Warning,
fix: Some(Fix {
range: 4..6,
replacement: " ".to_string(),
}),
rule_name: Some("MD009".to_string()),
},
];
let result = apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(result, "Test content");
}
#[test]
fn test_apply_fixes_with_windows_line_endings() {
let content = "1. First\r\n* Second\r\n";
let warnings = vec![
LintWarning {
message: "Too many spaces".to_string(),
line: 1,
column: 3,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: Some(Fix {
range: 2..4,
replacement: " ".to_string(),
}),
rule_name: Some("MD030".to_string()),
},
LintWarning {
message: "Too many spaces".to_string(),
line: 2,
column: 2,
end_line: 2,
end_column: 5,
severity: Severity::Warning,
fix: Some(Fix {
range: 12..15, replacement: " ".to_string(),
}),
rule_name: Some("MD030".to_string()),
},
];
let result = apply_warning_fixes(content, &warnings).unwrap();
assert!(result.contains("1. First"));
assert!(result.contains("* Second"));
}
#[test]
fn test_apply_fix_with_invalid_range() {
let content = "Short";
let warning = LintWarning {
message: "Invalid fix".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 10,
severity: Severity::Warning,
fix: Some(Fix {
range: 0..100, replacement: "Replacement".to_string(),
}),
rule_name: Some("TEST".to_string()),
};
let result = apply_warning_fixes(content, &[warning]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("exceeds content length"));
}
#[test]
fn test_apply_fix_with_reversed_range() {
let content = "Hello world";
let warning = LintWarning {
message: "Invalid fix".to_string(),
line: 1,
column: 5,
end_line: 1,
end_column: 3,
severity: Severity::Warning,
fix: Some(Fix {
#[allow(clippy::reversed_empty_ranges)]
range: 10..5, replacement: "Test".to_string(),
}),
rule_name: Some("TEST".to_string()),
};
let result = apply_warning_fixes(content, &[warning]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid fix range"));
}
#[test]
fn test_apply_no_fixes() {
let content = "No changes needed";
let warnings = vec![LintWarning {
message: "Warning without fix".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: None,
rule_name: Some("TEST".to_string()),
}];
let result = apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(result, content);
}
#[test]
fn test_warning_fix_to_edit() {
let content = "Hello world";
let warning = LintWarning {
message: "Test".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: Some(Fix {
range: 0..5,
replacement: "Hi".to_string(),
}),
rule_name: Some("TEST".to_string()),
};
let edit = warning_fix_to_edit(content, &warning).unwrap();
assert_eq!(edit, (0, 5, "Hi".to_string()));
}
#[test]
fn test_warning_fix_to_edit_no_fix() {
let content = "Hello world";
let warning = LintWarning {
message: "Test".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 5,
severity: Severity::Warning,
fix: None,
rule_name: Some("TEST".to_string()),
};
let result = warning_fix_to_edit(content, &warning);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Warning has no fix");
}
#[test]
fn test_warning_fix_to_edit_invalid_range() {
let content = "Short";
let warning = LintWarning {
message: "Test".to_string(),
line: 1,
column: 1,
end_line: 1,
end_column: 10,
severity: Severity::Warning,
fix: Some(Fix {
range: 0..100,
replacement: "Long replacement".to_string(),
}),
rule_name: Some("TEST".to_string()),
};
let result = warning_fix_to_edit(content, &warning);
assert!(result.is_err());
assert!(result.unwrap_err().contains("exceeds content length"));
}
#[test]
fn test_validate_fix_range() {
let content = "Hello world";
let valid_fix = Fix {
range: 0..5,
replacement: "Hi".to_string(),
};
assert!(validate_fix_range(content, &valid_fix).is_ok());
let invalid_fix = Fix {
range: 0..20,
replacement: "Hi".to_string(),
};
assert!(validate_fix_range(content, &invalid_fix).is_err());
let start = 5;
let end = 3;
let invalid_fix2 = Fix {
range: start..end,
replacement: "Hi".to_string(),
};
assert!(validate_fix_range(content, &invalid_fix2).is_err());
}
#[test]
fn test_validate_fix_range_edge_cases() {
let content = "Test";
let fix1 = Fix {
range: 0..0,
replacement: "Insert".to_string(),
};
assert!(validate_fix_range(content, &fix1).is_ok());
let fix2 = Fix {
range: 4..4,
replacement: " append".to_string(),
};
assert!(validate_fix_range(content, &fix2).is_ok());
let fix3 = Fix {
range: 0..4,
replacement: "Replace".to_string(),
};
assert!(validate_fix_range(content, &fix3).is_ok());
let fix4 = Fix {
range: 10..11,
replacement: "Invalid".to_string(),
};
let result = validate_fix_range(content, &fix4);
assert!(result.is_err());
assert!(result.unwrap_err().contains("start 10 exceeds"));
}
#[test]
fn test_fix_ordering_stability() {
let content = "Test content here";
let warnings = vec![
LintWarning {
message: "First warning".to_string(),
line: 1,
column: 6,
end_line: 1,
end_column: 13,
severity: Severity::Warning,
fix: Some(Fix {
range: 5..12, replacement: "stuff".to_string(),
}),
rule_name: Some("MD001".to_string()),
},
LintWarning {
message: "Second warning".to_string(),
line: 1,
column: 6,
end_line: 1,
end_column: 13,
severity: Severity::Warning,
fix: Some(Fix {
range: 5..12, replacement: "stuff".to_string(),
}),
rule_name: Some("MD002".to_string()),
},
];
let result = apply_warning_fixes(content, &warnings).unwrap();
assert_eq!(result, "Test stuff here");
}
#[test]
fn test_line_ending_preservation() {
let content_unix = "Line 1\nLine 2\n";
let warning = LintWarning {
message: "Add text".to_string(),
line: 1,
column: 7,
end_line: 1,
end_column: 7,
severity: Severity::Warning,
fix: Some(Fix {
range: 6..6,
replacement: " added".to_string(),
}),
rule_name: Some("TEST".to_string()),
};
let result = apply_warning_fixes(content_unix, &[warning]).unwrap();
assert_eq!(result, "Line 1 added\nLine 2\n");
let content_windows = "Line 1\r\nLine 2\r\n";
let warning_windows = LintWarning {
message: "Add text".to_string(),
line: 1,
column: 7,
end_line: 1,
end_column: 7,
severity: Severity::Warning,
fix: Some(Fix {
range: 6..6,
replacement: " added".to_string(),
}),
rule_name: Some("TEST".to_string()),
};
let result_windows = apply_warning_fixes(content_windows, &[warning_windows]).unwrap();
assert!(result_windows.starts_with("Line 1 added"));
assert!(result_windows.contains("Line 2"));
}
fn make_warning(line: usize, end_line: usize, rule_name: &str) -> LintWarning {
LintWarning {
message: "test".to_string(),
line,
column: 1,
end_line,
end_column: 1,
severity: Severity::Warning,
fix: Some(Fix {
range: 0..1,
replacement: "x".to_string(),
}),
rule_name: Some(rule_name.to_string()),
}
}
#[test]
fn test_filter_warnings_disable_enable_block() {
let content =
"# Heading\n\n<!-- rumdl-disable MD013 -->\nlong line\n<!-- rumdl-enable MD013 -->\nanother long line\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![
make_warning(4, 4, "MD013"), make_warning(6, 6, "MD013"), ];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 6);
}
#[test]
fn test_filter_warnings_disable_line() {
let content = "line one <!-- rumdl-disable-line MD009 -->\nline two\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![
make_warning(1, 1, "MD009"), make_warning(2, 2, "MD009"), ];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD009");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 2);
}
#[test]
fn test_filter_warnings_disable_next_line() {
let content = "<!-- rumdl-disable-next-line MD034 -->\nhttp://example.com\nhttp://other.com\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![
make_warning(2, 2, "MD034"), make_warning(3, 3, "MD034"), ];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD034");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 3);
}
#[test]
fn test_filter_warnings_sub_rule_name() {
let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029")];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 4);
}
#[test]
fn test_filter_warnings_multi_line_warning() {
let content = "line 1\nline 2\nline 3\n<!-- rumdl-disable-line MD013 -->\nline 5\nline 6\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![
make_warning(3, 5, "MD013"), make_warning(6, 6, "MD013"), ];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 6);
}
#[test]
fn test_filter_warnings_empty_input() {
let inline_config = InlineConfig::from_content("");
let filtered = filter_warnings_by_inline_config(vec![], &inline_config, "MD013");
assert!(filtered.is_empty());
}
#[test]
fn test_filter_warnings_none_disabled() {
let content = "line 1\nline 2\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![make_warning(1, 1, "MD013"), make_warning(2, 2, "MD013")];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_warnings_all_disabled() {
let content = "<!-- rumdl-disable MD013 -->\nline 1\nline 2\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![make_warning(2, 2, "MD013"), make_warning(3, 3, "MD013")];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
assert!(filtered.is_empty());
}
#[test]
fn test_filter_warnings_end_line_zero_fallback() {
let content = "<!-- rumdl-disable-line MD013 -->\nline 2\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![make_warning(1, 0, "MD013")];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
assert!(filtered.is_empty());
}
#[test]
fn test_filter_non_md_rule_name_preserves_dash() {
let content = "line 1\nline 2\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![make_warning(1, 1, "custom-rule")];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "custom-rule");
assert_eq!(filtered.len(), 1, "Non-MD rule name with dash should not be split");
}
#[test]
fn test_filter_md_sub_rule_name_is_split() {
let content = "<!-- rumdl-disable MD029 -->\nline\n<!-- rumdl-enable MD029 -->\nline\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![
make_warning(2, 2, "MD029"), make_warning(4, 4, "MD029"), ];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD029-style");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 4);
}
#[test]
fn test_filter_warnings_capture_restore() {
let content = "<!-- rumdl-disable MD013 -->\nline 1\n<!-- rumdl-capture -->\n<!-- rumdl-enable MD013 -->\nline 4\n<!-- rumdl-restore -->\nline 6\n";
let inline_config = InlineConfig::from_content(content);
let warnings = vec![
make_warning(2, 2, "MD013"), make_warning(5, 5, "MD013"), make_warning(7, 7, "MD013"), ];
let filtered = filter_warnings_by_inline_config(warnings, &inline_config, "MD013");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].line, 5);
}
}