Skip to main content

mdlint/fix/
fixer.rs

1use crate::error::{MarkdownlintError, Result};
2use crate::types::{FileResult, Fix};
3use std::fs;
4use std::path::Path;
5
6pub struct Fixer {
7    dry_run: bool,
8}
9
10impl Fixer {
11    pub fn new() -> Self {
12        Self { dry_run: false }
13    }
14
15    pub fn with_dry_run(dry_run: bool) -> Self {
16        Self { dry_run }
17    }
18
19    /// Apply fixes to a file and return the fixed content
20    pub fn apply_fixes(&self, path: &Path, fixes: &[Fix]) -> Result<String> {
21        let content = fs::read_to_string(path)?;
22        let fixed = self.apply_fixes_to_content(&content, fixes)?;
23        Ok(fixed)
24    }
25
26    /// Apply fixes to content string
27    pub fn apply_fixes_to_content(&self, content: &str, fixes: &[Fix]) -> Result<String> {
28        if fixes.is_empty() {
29            return Ok(content.to_string());
30        }
31
32        // Detect line ending style
33        let line_ending = detect_line_ending(content);
34
35        // Split into lines
36        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
37
38        // Sort fixes in reverse order (by line, then by column) to apply from end to start
39        let mut sorted_fixes = fixes.to_vec();
40        sorted_fixes.sort_by(|a, b| {
41            match b.line_start.cmp(&a.line_start) {
42                std::cmp::Ordering::Equal => {
43                    // If same line, sort by column (reverse)
44                    match (&b.column_start, &a.column_start) {
45                        (Some(bc), Some(ac)) => bc.cmp(ac),
46                        _ => std::cmp::Ordering::Equal,
47                    }
48                }
49                other => other,
50            }
51        });
52
53        // Check for overlapping fixes
54        if has_overlaps(&sorted_fixes) {
55            return Err(MarkdownlintError::Fix(
56                "Cannot apply fixes: overlapping fix ranges detected".to_string(),
57            ));
58        }
59
60        // Apply each fix
61        for fix in sorted_fixes {
62            apply_single_fix(&mut lines, &fix)?;
63        }
64
65        // Rejoin with original line ending
66        Ok(lines.join(line_ending))
67    }
68
69    /// Apply fixes from a FileResult and write to disk
70    pub fn apply_file_fixes(&self, file_result: &FileResult) -> Result<()> {
71        let fixes: Vec<Fix> = file_result
72            .violations
73            .iter()
74            .filter_map(|v| v.fix.clone())
75            .collect();
76
77        if fixes.is_empty() {
78            return Ok(());
79        }
80
81        let fixed_content = self.apply_fixes(&file_result.path, &fixes)?;
82
83        if !self.dry_run {
84            fs::write(&file_result.path, fixed_content)?;
85        }
86
87        Ok(())
88    }
89}
90
91impl Default for Fixer {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97/// Detect line ending style (\n or \r\n)
98fn detect_line_ending(content: &str) -> &str {
99    if content.contains("\r\n") {
100        "\r\n"
101    } else {
102        "\n"
103    }
104}
105
106/// Check if any fixes overlap
107fn has_overlaps(fixes: &[Fix]) -> bool {
108    for i in 0..fixes.len() {
109        for j in (i + 1)..fixes.len() {
110            if fixes_overlap(&fixes[i], &fixes[j]) {
111                return true;
112            }
113        }
114    }
115    false
116}
117
118/// Check if two fixes overlap
119fn fixes_overlap(a: &Fix, b: &Fix) -> bool {
120    // If fixes are on different lines and don't span, they don't overlap
121    if a.line_end < b.line_start || b.line_end < a.line_start {
122        return false;
123    }
124
125    // If they share any lines, check column overlap
126    if a.line_start == b.line_start && a.line_end == b.line_end {
127        match (
128            &a.column_start,
129            &a.column_end,
130            &b.column_start,
131            &b.column_end,
132        ) {
133            (Some(a_start), Some(a_end), Some(b_start), Some(b_end)) => {
134                // Check column overlap
135                !(a_end < b_start || b_end < a_start)
136            }
137            _ => true, // If columns not specified, assume overlap
138        }
139    } else {
140        true // Multi-line fixes that touch same lines overlap
141    }
142}
143
144/// Apply a single fix to the lines
145fn apply_single_fix(lines: &mut Vec<String>, fix: &Fix) -> Result<()> {
146    // Convert to 0-indexed
147    let start_line = fix.line_start.saturating_sub(1);
148    let end_line = fix.line_end.saturating_sub(1);
149
150    if start_line >= lines.len() {
151        return Err(MarkdownlintError::Fix(format!(
152            "Fix start line {} out of bounds",
153            fix.line_start
154        )));
155    }
156
157    if end_line >= lines.len() {
158        return Err(MarkdownlintError::Fix(format!(
159            "Fix end line {} out of bounds",
160            fix.line_end
161        )));
162    }
163
164    // Handle column-based fixes (single line, specific columns)
165    if start_line == end_line
166        && let (Some(col_start), Some(col_end)) = (fix.column_start, fix.column_end)
167    {
168        let line = &lines[start_line];
169        let chars: Vec<char> = line.chars().collect();
170
171        if col_start > chars.len() || col_end > chars.len() {
172            return Err(MarkdownlintError::Fix(format!(
173                "Fix column range {}..{} out of bounds for line length {}",
174                col_start,
175                col_end,
176                chars.len()
177            )));
178        }
179
180        // Build new line with replacement
181        let before: String = chars[..col_start.saturating_sub(1)].iter().collect();
182        let after: String = chars[col_end..].iter().collect();
183        lines[start_line] = format!("{}{}{}", before, fix.replacement, after);
184        return Ok(());
185    }
186
187    // Handle line-based fixes (replace entire lines)
188    if start_line == end_line {
189        // Single line replacement
190        lines[start_line] = fix.replacement.clone();
191    } else {
192        // Multi-line replacement
193        let replacement_lines: Vec<String> =
194            fix.replacement.lines().map(|l| l.to_string()).collect();
195
196        // Remove old lines and insert new ones
197        lines.splice(start_line..=end_line, replacement_lines);
198    }
199
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_detect_line_ending_lf() {
209        let content = "line1\nline2\nline3";
210        assert_eq!(detect_line_ending(content), "\n");
211    }
212
213    #[test]
214    fn test_detect_line_ending_crlf() {
215        let content = "line1\r\nline2\r\nline3";
216        assert_eq!(detect_line_ending(content), "\r\n");
217    }
218
219    #[test]
220    fn test_apply_single_line_fix() {
221        let content = "line 1\nline 2\nline 3";
222        let fix = Fix {
223            line_start: 2,
224            line_end: 2,
225            column_start: None,
226            column_end: None,
227            replacement: "REPLACED".to_string(),
228            description: "Test".to_string(),
229        };
230
231        let fixer = Fixer::new();
232        let result = fixer.apply_fixes_to_content(content, &[fix]).unwrap();
233        assert_eq!(result, "line 1\nREPLACED\nline 3");
234    }
235
236    #[test]
237    fn test_apply_column_fix() {
238        let content = "hello world";
239        let fix = Fix {
240            line_start: 1,
241            line_end: 1,
242            column_start: Some(7), // "world" starts at column 7 (1-indexed)
243            column_end: Some(11),  // ends at column 11
244            replacement: "Rust".to_string(),
245            description: "Test".to_string(),
246        };
247
248        let fixer = Fixer::new();
249        let result = fixer.apply_fixes_to_content(content, &[fix]).unwrap();
250        assert_eq!(result, "hello Rust");
251    }
252
253    #[test]
254    fn test_multiple_fixes_reverse_order() {
255        let content = "line 1\nline 2\nline 3";
256        let fixes = vec![
257            Fix {
258                line_start: 1,
259                line_end: 1,
260                column_start: None,
261                column_end: None,
262                replacement: "FIRST".to_string(),
263                description: "Test".to_string(),
264            },
265            Fix {
266                line_start: 3,
267                line_end: 3,
268                column_start: None,
269                column_end: None,
270                replacement: "THIRD".to_string(),
271                description: "Test".to_string(),
272            },
273        ];
274
275        let fixer = Fixer::new();
276        let result = fixer.apply_fixes_to_content(content, &fixes).unwrap();
277        assert_eq!(result, "FIRST\nline 2\nTHIRD");
278    }
279
280    #[test]
281    fn test_preserve_crlf() {
282        let content = "line 1\r\nline 2\r\nline 3";
283        let fix = Fix {
284            line_start: 2,
285            line_end: 2,
286            column_start: None,
287            column_end: None,
288            replacement: "FIXED".to_string(),
289            description: "Test".to_string(),
290        };
291
292        let fixer = Fixer::new();
293        let result = fixer.apply_fixes_to_content(content, &[fix]).unwrap();
294        assert_eq!(result, "line 1\r\nFIXED\r\nline 3");
295    }
296}