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