use crate::error::{MarkdownlintError, Result};
use crate::types::{FileResult, Fix};
use std::fs;
use std::path::Path;
pub struct Fixer {
dry_run: bool,
}
impl Fixer {
pub fn new() -> Self {
Self { dry_run: false }
}
pub fn with_dry_run(dry_run: bool) -> Self {
Self { dry_run }
}
pub fn apply_fixes(&self, path: &Path, fixes: &[Fix]) -> Result<String> {
let content = fs::read_to_string(path)?;
let fixed = self.apply_fixes_to_content(&content, fixes)?;
Ok(fixed)
}
pub fn apply_fixes_to_content(&self, content: &str, fixes: &[Fix]) -> Result<String> {
if fixes.is_empty() {
return Ok(content.to_string());
}
let line_ending = detect_line_ending(content);
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let mut sorted_fixes = fixes.to_vec();
sorted_fixes.sort_by(|a, b| {
match b.line_start.cmp(&a.line_start) {
std::cmp::Ordering::Equal => {
match (&b.column_start, &a.column_start) {
(Some(bc), Some(ac)) => bc.cmp(ac),
_ => std::cmp::Ordering::Equal,
}
}
other => other,
}
});
if has_overlaps(&sorted_fixes) {
return Err(MarkdownlintError::Fix(
"Cannot apply fixes: overlapping fix ranges detected".to_string(),
));
}
for fix in sorted_fixes {
apply_single_fix(&mut lines, &fix)?;
}
Ok(lines.join(line_ending))
}
pub fn apply_file_fixes(&self, file_result: &FileResult) -> Result<()> {
let fixes: Vec<Fix> = file_result
.violations
.iter()
.filter_map(|v| v.fix.clone())
.collect();
if fixes.is_empty() {
return Ok(());
}
let fixed_content = self.apply_fixes(&file_result.path, &fixes)?;
if !self.dry_run {
fs::write(&file_result.path, fixed_content)?;
}
Ok(())
}
}
impl Default for Fixer {
fn default() -> Self {
Self::new()
}
}
fn detect_line_ending(content: &str) -> &str {
if content.contains("\r\n") {
"\r\n"
} else {
"\n"
}
}
fn has_overlaps(fixes: &[Fix]) -> bool {
for i in 0..fixes.len() {
for j in (i + 1)..fixes.len() {
if fixes_overlap(&fixes[i], &fixes[j]) {
return true;
}
}
}
false
}
fn fixes_overlap(a: &Fix, b: &Fix) -> bool {
if a.line_end < b.line_start || b.line_end < a.line_start {
return false;
}
if a.line_start == b.line_start && a.line_end == b.line_end {
match (
&a.column_start,
&a.column_end,
&b.column_start,
&b.column_end,
) {
(Some(a_start), Some(a_end), Some(b_start), Some(b_end)) => {
!(a_end < b_start || b_end < a_start)
}
_ => true, }
} else {
true }
}
fn apply_single_fix(lines: &mut Vec<String>, fix: &Fix) -> Result<()> {
let start_line = fix.line_start.saturating_sub(1);
let end_line = fix.line_end.saturating_sub(1);
if start_line >= lines.len() {
return Err(MarkdownlintError::Fix(format!(
"Fix start line {} out of bounds",
fix.line_start
)));
}
if end_line >= lines.len() {
return Err(MarkdownlintError::Fix(format!(
"Fix end line {} out of bounds",
fix.line_end
)));
}
if start_line == end_line
&& let (Some(col_start), Some(col_end)) = (fix.column_start, fix.column_end)
{
let line = &lines[start_line];
let chars: Vec<char> = line.chars().collect();
if col_start > chars.len() || col_end > chars.len() {
return Err(MarkdownlintError::Fix(format!(
"Fix column range {}..{} out of bounds for line length {}",
col_start,
col_end,
chars.len()
)));
}
let before: String = chars[..col_start.saturating_sub(1)].iter().collect();
let after: String = chars[col_end..].iter().collect();
lines[start_line] = format!("{}{}{}", before, fix.replacement, after);
return Ok(());
}
if start_line == end_line {
lines[start_line] = fix.replacement.clone();
} else {
let replacement_lines: Vec<String> =
fix.replacement.lines().map(|l| l.to_string()).collect();
lines.splice(start_line..=end_line, replacement_lines);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_line_ending_lf() {
let content = "line1\nline2\nline3";
assert_eq!(detect_line_ending(content), "\n");
}
#[test]
fn test_detect_line_ending_crlf() {
let content = "line1\r\nline2\r\nline3";
assert_eq!(detect_line_ending(content), "\r\n");
}
#[test]
fn test_apply_single_line_fix() {
let content = "line 1\nline 2\nline 3";
let fix = Fix {
line_start: 2,
line_end: 2,
column_start: None,
column_end: None,
replacement: "REPLACED".to_string(),
description: "Test".to_string(),
};
let fixer = Fixer::new();
let result = fixer.apply_fixes_to_content(content, &[fix]).unwrap();
assert_eq!(result, "line 1\nREPLACED\nline 3");
}
#[test]
fn test_apply_column_fix() {
let content = "hello world";
let fix = Fix {
line_start: 1,
line_end: 1,
column_start: Some(7), column_end: Some(11), replacement: "Rust".to_string(),
description: "Test".to_string(),
};
let fixer = Fixer::new();
let result = fixer.apply_fixes_to_content(content, &[fix]).unwrap();
assert_eq!(result, "hello Rust");
}
#[test]
fn test_multiple_fixes_reverse_order() {
let content = "line 1\nline 2\nline 3";
let fixes = vec![
Fix {
line_start: 1,
line_end: 1,
column_start: None,
column_end: None,
replacement: "FIRST".to_string(),
description: "Test".to_string(),
},
Fix {
line_start: 3,
line_end: 3,
column_start: None,
column_end: None,
replacement: "THIRD".to_string(),
description: "Test".to_string(),
},
];
let fixer = Fixer::new();
let result = fixer.apply_fixes_to_content(content, &fixes).unwrap();
assert_eq!(result, "FIRST\nline 2\nTHIRD");
}
#[test]
fn test_preserve_crlf() {
let content = "line 1\r\nline 2\r\nline 3";
let fix = Fix {
line_start: 2,
line_end: 2,
column_start: None,
column_end: None,
replacement: "FIXED".to_string(),
description: "Test".to_string(),
};
let fixer = Fixer::new();
let result = fixer.apply_fixes_to_content(content, &[fix]).unwrap();
assert_eq!(result, "line 1\r\nFIXED\r\nline 3");
}
}