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 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 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 let line_ending = detect_line_ending(content);
34
35 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
37
38 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 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 if has_overlaps(&sorted_fixes) {
55 return Err(MarkdownlintError::Fix(
56 "Cannot apply fixes: overlapping fix ranges detected".to_string(),
57 ));
58 }
59
60 for fix in sorted_fixes {
62 apply_single_fix(&mut lines, &fix)?;
63 }
64
65 Ok(lines.join(line_ending))
67 }
68
69 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
97fn detect_line_ending(content: &str) -> &str {
99 if content.contains("\r\n") {
100 "\r\n"
101 } else {
102 "\n"
103 }
104}
105
106fn 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
118fn fixes_overlap(a: &Fix, b: &Fix) -> bool {
120 if a.line_end < b.line_start || b.line_end < a.line_start {
122 return false;
123 }
124
125 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 !(a_end < b_start || b_end < a_start)
136 }
137 _ => true, }
139 } else {
140 true }
142}
143
144fn apply_single_fix(lines: &mut Vec<String>, fix: &Fix) -> Result<()> {
146 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 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 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 if start_line == end_line {
189 lines[start_line] = fix.replacement.clone();
191 } else {
192 let replacement_lines: Vec<String> =
194 fix.replacement.lines().map(|l| l.to_string()).collect();
195
196 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), column_end: Some(11), 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}