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 let had_trailing_newline = content.ends_with('\n');
35
36 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
38
39 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 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 if has_overlaps(&sorted_fixes) {
56 return Err(MarkdownlintError::Fix(
57 "Cannot apply fixes: overlapping fix ranges detected".to_string(),
58 ));
59 }
60
61 for fix in sorted_fixes {
63 apply_single_fix(&mut lines, &fix)?;
64 }
65
66 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 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
103fn detect_line_ending(content: &str) -> &str {
105 if content.contains("\r\n") {
106 "\r\n"
107 } else {
108 "\n"
109 }
110}
111
112fn 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
124fn fixes_overlap(a: &Fix, b: &Fix) -> bool {
126 if a.line_end < b.line_start || b.line_end < a.line_start {
128 return false;
129 }
130
131 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 !(a_end < b_start || b_end < a_start)
142 }
143 _ => true, }
145 } else {
146 true }
148}
149
150fn apply_single_fix(lines: &mut Vec<String>, fix: &Fix) -> Result<()> {
152 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 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 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 if start_line == end_line {
195 if fix.replacement.is_empty() && fix.column_start.is_none() {
196 lines.remove(start_line);
198 } else {
199 lines[start_line] = fix.replacement.clone();
200 }
201 } else {
202 let replacement_lines: Vec<String> =
204 fix.replacement.lines().map(|l| l.to_string()).collect();
205
206 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), column_end: Some(11), 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}