1use similar::{udiff::unified_diff, Algorithm, TextDiff};
10
11use crate::error::FileError;
12
13pub struct BomResult<'a> {
15 pub bom: &'static str,
16 pub text: &'a str,
17}
18
19pub fn strip_bom(content: &str) -> BomResult<'_> {
22 const BOM: &str = "\u{FEFF}";
23 if let Some(rest) = content.strip_prefix(BOM) {
24 BomResult {
25 bom: BOM,
26 text: rest,
27 }
28 } else {
29 BomResult {
30 bom: "",
31 text: content,
32 }
33 }
34}
35
36pub fn detect_line_ending(content: &str) -> &'static str {
39 let first_crlf = content.find("\r\n");
40 let first_lf = content.find('\n');
41 match (first_crlf, first_lf) {
42 (None, None) => "\n",
43 (Some(_), None) => "\n",
44 (Some(crlf), Some(lf)) => {
45 if crlf <= lf {
46 "\r\n"
47 } else {
48 "\n"
49 }
50 }
51 (None, Some(_)) => "\n",
52 }
53}
54
55pub fn normalize_to_lf(text: &str) -> String {
57 text.replace("\r\n", "\n").replace('\r', "\n")
58}
59
60pub fn restore_line_endings(text: &str, ending: &str) -> String {
62 if ending == "\r\n" {
63 text.replace('\n', "\r\n")
64 } else {
65 text.to_string()
66 }
67}
68
69pub fn normalize_for_fuzzy_match(text: &str) -> String {
72 use unicode_normalization::UnicodeNormalization;
73 let nfkc: String = text.nfkc().collect();
74 let trimmed: String = nfkc
76 .split('\n')
77 .map(|line| line.trim_end())
78 .collect::<Vec<_>>()
79 .join("\n");
80 let mut out = String::with_capacity(trimmed.len());
81 for c in trimmed.chars() {
82 match c {
83 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => out.push('\''),
84 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
85 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
86 | '\u{2212}' => out.push('-'),
87 '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
88 | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
89 | '\u{3000}' => out.push(' '),
90 other => out.push(other),
91 }
92 }
93 out
94}
95
96#[derive(Clone)]
98pub struct FuzzyMatchResult {
99 pub found: bool,
100 pub index: usize,
101 pub match_length: usize,
102 pub used_fuzzy_match: bool,
103 pub content_for_replacement: String,
105}
106
107pub fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
110 if let Some(idx) = content.find(old_text) {
111 return FuzzyMatchResult {
112 found: true,
113 index: idx,
114 match_length: old_text.len(),
115 used_fuzzy_match: false,
116 content_for_replacement: content.to_string(),
117 };
118 }
119 let fuzzy_content = normalize_for_fuzzy_match(content);
120 let fuzzy_old = normalize_for_fuzzy_match(old_text);
121 if let Some(idx) = fuzzy_content.find(&fuzzy_old) {
122 return FuzzyMatchResult {
123 found: true,
124 index: idx,
125 match_length: fuzzy_old.len(),
126 used_fuzzy_match: true,
127 content_for_replacement: fuzzy_content,
128 };
129 }
130 FuzzyMatchResult {
131 found: false,
132 index: usize::MAX,
133 match_length: 0,
134 used_fuzzy_match: false,
135 content_for_replacement: content.to_string(),
136 }
137}
138
139pub fn count_occurrences(content: &str, old_text: &str) -> usize {
142 let fc = normalize_for_fuzzy_match(content);
143 let fo = normalize_for_fuzzy_match(old_text);
144 if fo.is_empty() {
145 return 0;
146 }
147 fc.matches(&fo).count()
148}
149
150#[derive(Clone)]
152pub struct ReplaceEdit {
153 pub old_text: String,
154 pub new_text: String,
155}
156
157#[derive(Clone)]
159struct MatchedEdit {
160 edit_index: usize,
161 match_index: usize,
162 match_length: usize,
163 new_text: String,
164}
165
166#[derive(Debug)]
168pub struct ApplyResult {
169 pub base_content: String,
170 pub new_content: String,
171 pub used_fuzzy_match: bool,
172}
173
174pub fn apply_edits_to_normalized_content(
177 normalized_content: &str,
178 edits: &[ReplaceEdit],
179 path: &str,
180) -> Result<ApplyResult, FileError> {
181 let total = edits.len();
182 let normalized_edits: Vec<ReplaceEdit> = edits
183 .iter()
184 .map(|e| ReplaceEdit {
185 old_text: normalize_to_lf(&e.old_text),
186 new_text: normalize_to_lf(&e.new_text),
187 })
188 .collect();
189
190 for (i, e) in normalized_edits.iter().enumerate() {
192 if e.old_text.is_empty() {
193 return Err(FileError::new(
194 crate::error::FileErrorCode::Invalid,
195 empty_old_text_error(path, i, total),
196 )
197 .with_path(path));
198 }
199 }
200
201 let initial_matches: Vec<FuzzyMatchResult> = normalized_edits
202 .iter()
203 .map(|e| fuzzy_find_text(normalized_content, &e.old_text))
204 .collect();
205 let used_fuzzy_match = initial_matches.iter().any(|m| m.used_fuzzy_match);
206 let replacement_base_content = if used_fuzzy_match {
207 normalize_for_fuzzy_match(normalized_content)
208 } else {
209 normalized_content.to_string()
210 };
211
212 let mut matched_edits: Vec<MatchedEdit> = Vec::new();
213 for (i, e) in normalized_edits.iter().enumerate() {
214 let m = fuzzy_find_text(&replacement_base_content, &e.old_text);
215 if !m.found {
216 return Err(FileError::new(
217 crate::error::FileErrorCode::NotFound,
218 not_found_error(path, i, total),
219 )
220 .with_path(path));
221 }
222 let occurrences = count_occurrences(&replacement_base_content, &e.old_text);
223 if occurrences > 1 {
224 return Err(FileError::new(
225 crate::error::FileErrorCode::Invalid,
226 duplicate_error(path, i, total, occurrences),
227 )
228 .with_path(path));
229 }
230 matched_edits.push(MatchedEdit {
231 edit_index: i,
232 match_index: m.index,
233 match_length: m.match_length,
234 new_text: e.new_text.clone(),
235 });
236 }
237
238 matched_edits.sort_by_key(|m| m.match_index);
240 for pair in matched_edits.windows(2) {
241 let prev = &pair[0];
242 let cur = &pair[1];
243 if prev.match_index + prev.match_length > cur.match_index {
244 return Err(FileError::new(
245 crate::error::FileErrorCode::Invalid,
246 format!(
247 "edits[{}] and edits[{}] overlap in {}. Merge them into one edit or target disjoint regions.",
248 prev.edit_index, cur.edit_index, path
249 ),
250 )
251 .with_path(path));
252 }
253 }
254
255 let base_content = normalized_content.to_string();
256 let new_content = if used_fuzzy_match {
257 apply_replacements_preserving_unchanged_lines(
258 normalized_content,
259 &replacement_base_content,
260 &matched_edits,
261 )?
262 } else {
263 apply_replacements(&replacement_base_content, &matched_edits)
264 };
265
266 if base_content == new_content {
267 return Err(FileError::new(
268 crate::error::FileErrorCode::Invalid,
269 no_change_error(path, total),
270 )
271 .with_path(path));
272 }
273
274 Ok(ApplyResult {
275 base_content,
276 new_content,
277 used_fuzzy_match,
278 })
279}
280
281fn apply_replacements(content: &str, replacements: &[MatchedEdit]) -> String {
283 let mut result = content.to_string();
284 let mut sorted = replacements.to_vec();
285 sorted.sort_by(|a, b| b.match_index.cmp(&a.match_index));
286 for r in sorted {
287 let start = r.match_index;
288 let end = start + r.match_length;
289 if end <= result.len() {
290 result.replace_range(start..end, &r.new_text);
291 }
292 }
293 result
294}
295
296fn apply_replacements_preserving_unchanged_lines(
299 original_content: &str,
300 base_content: &str,
301 replacements: &[MatchedEdit],
302) -> Result<String, FileError> {
303 let original_lines = split_lines_with_endings(original_content);
304 let base_lines = get_line_spans(base_content);
305 if original_lines.len() != base_lines.len() {
306 return Err(FileError::new(
307 crate::error::FileErrorCode::Invalid,
308 "Cannot preserve unchanged lines because the base content has a different line count.",
309 ));
310 }
311 let mut sorted = replacements.to_vec();
313 sorted.sort_by(|a, b| a.match_index.cmp(&b.match_index));
314 let mut groups: Vec<(usize, usize, Vec<MatchedEdit>)> = Vec::new();
315 for r in &sorted {
316 let range =
317 get_replacement_line_range(&base_lines, r.match_index, r.match_index + r.match_length);
318 if let Some(last) = groups.last_mut() {
319 if range.start_line < last.1 {
320 last.1 = last.1.max(range.end_line);
321 last.2.push(r.clone());
322 continue;
323 }
324 }
325 groups.push((range.start_line, range.end_line, vec![r.clone()]));
326 }
327
328 let mut out = String::new();
329 let mut original_line_idx = 0usize;
330 for (start_line, end_line, reps) in &groups {
331 if original_line_idx < *start_line {
333 out.push_str(&original_lines[original_line_idx..*start_line].join(""));
334 }
335 let group_start_offset = base_lines[*start_line].0;
337 let group_end_offset = base_lines[*end_line - 1].1;
338 let group_slice = &base_content[group_start_offset..group_end_offset];
339 let offset_reps: Vec<MatchedEdit> = reps
341 .iter()
342 .map(|r| MatchedEdit {
343 edit_index: r.edit_index,
344 match_index: r.match_index - group_start_offset,
345 match_length: r.match_length,
346 new_text: r.new_text.clone(),
347 })
348 .collect();
349 out.push_str(&apply_replacements(group_slice, &offset_reps));
350 original_line_idx = *end_line;
351 }
352 if original_line_idx < original_lines.len() {
354 out.push_str(&original_lines[original_line_idx..].join(""));
355 }
356 Ok(out)
357}
358
359fn split_lines_with_endings(content: &str) -> Vec<String> {
362 let mut out = Vec::new();
363 let mut acc = String::new();
364 for c in content.chars() {
365 acc.push(c);
366 if c == '\n' {
367 out.push(std::mem::take(&mut acc));
368 }
369 }
370 if !acc.is_empty() {
371 out.push(acc);
372 }
373 out
374}
375
376fn get_line_spans(content: &str) -> Vec<(usize, usize)> {
378 let mut spans = Vec::new();
379 let mut start = 0usize;
380 for (i, c) in content.char_indices() {
381 if c == '\n' {
382 spans.push((start, i + 1));
383 start = i + 1;
384 }
385 }
386 if start < content.len() {
387 spans.push((start, content.len()));
388 }
389 spans
390}
391
392struct LineRange {
393 start_line: usize,
394 end_line: usize,
395}
396
397fn get_replacement_line_range(
400 lines: &[(usize, usize)],
401 start_offset: usize,
402 end_offset: usize,
403) -> LineRange {
404 let mut start_line = 0usize;
405 for (i, span) in lines.iter().enumerate() {
406 if start_offset >= span.0 && start_offset < span.1 {
407 start_line = i;
408 break;
409 }
410 start_line = i + 1;
411 }
412 let mut end_line = start_line + 1;
413 while end_line < lines.len() && lines[end_line - 1].1 < end_offset {
414 end_line += 1;
415 }
416 LineRange {
417 start_line,
418 end_line,
419 }
420}
421
422pub struct DiffResult {
424 pub diff: String,
425 pub first_changed_line: Option<usize>,
426}
427
428pub fn generate_diff_string(
431 old_content: &str,
432 new_content: &str,
433 context_lines: usize,
434) -> DiffResult {
435 let parts = TextDiff::from_lines(old_content, new_content);
436 let ops: Vec<_> = parts.ops().to_vec();
437 let old_lines: Vec<&str> = old_content.split('\n').collect();
438 let new_lines: Vec<&str> = new_content.split('\n').collect();
439 let max_line_num = old_lines.len().max(new_lines.len());
440 let line_num_width = max_line_num.to_string().len();
441
442 let mut out: Vec<String> = Vec::new();
443 let mut old_line_num = 1usize;
444 let mut new_line_num = 1usize;
445 let mut last_was_change = false;
446 let mut first_changed_line: Option<usize> = None;
447
448 for (i, op) in ops.iter().enumerate() {
449 let tag = op.tag();
450 let mut raw: Vec<&str> = match tag {
453 similar::DiffTag::Equal | similar::DiffTag::Delete => {
454 old_lines[op.old_range().start..op.old_range().end].to_vec()
455 }
456 _ => new_lines[op.new_range().start..op.new_range().end].to_vec(),
457 };
458 if raw.last().map(|s| s.is_empty()).unwrap_or(false) {
459 raw.pop();
460 }
461 let raw_len = raw.len();
462
463 let is_added = matches!(tag, similar::DiffTag::Insert | similar::DiffTag::Replace);
464 let is_removed = matches!(tag, similar::DiffTag::Delete | similar::DiffTag::Replace);
465
466 if is_added || is_removed {
467 if first_changed_line.is_none() {
468 first_changed_line = Some(new_line_num);
469 }
470 for line in &raw {
471 if is_added {
472 out.push(format!(
473 "+{:>width$} {}",
474 new_line_num,
475 line,
476 width = line_num_width
477 ));
478 new_line_num += 1;
479 }
480 if is_removed {
481 out.push(format!(
482 "-{:>width$} {}",
483 old_line_num,
484 line,
485 width = line_num_width
486 ));
487 old_line_num += 1;
488 }
489 }
490 last_was_change = true;
493 let _ = i; } else {
495 let next_is_change = i + 1 < ops.len()
497 && matches!(
498 ops[i + 1].tag(),
499 similar::DiffTag::Insert | similar::DiffTag::Delete | similar::DiffTag::Replace
500 );
501 let has_leading_change = last_was_change;
502 let has_trailing_change = next_is_change;
503
504 if has_leading_change && has_trailing_change {
505 if raw_len <= context_lines * 2 {
506 for line in &raw {
507 out.push(format!(
508 " {:>width$} {}",
509 old_line_num,
510 line,
511 width = line_num_width
512 ));
513 old_line_num += 1;
514 new_line_num += 1;
515 }
516 } else {
517 let leading = &raw[..context_lines];
518 let trailing = &raw[raw_len - context_lines..];
519 let skipped = raw_len - leading.len() - trailing.len();
520 for line in leading {
521 out.push(format!(
522 " {:>width$} {}",
523 old_line_num,
524 line,
525 width = line_num_width
526 ));
527 old_line_num += 1;
528 new_line_num += 1;
529 }
530 out.push(format!(" {:>width$} ...", "", width = line_num_width));
531 old_line_num += skipped;
532 new_line_num += skipped;
533 for line in trailing {
534 out.push(format!(
535 " {:>width$} {}",
536 old_line_num,
537 line,
538 width = line_num_width
539 ));
540 old_line_num += 1;
541 new_line_num += 1;
542 }
543 }
544 } else if has_leading_change {
545 let shown = if raw_len <= context_lines {
546 &raw[..]
547 } else {
548 &raw[..context_lines]
549 };
550 let skipped = raw_len - shown.len();
551 for line in shown {
552 out.push(format!(
553 " {:>width$} {}",
554 old_line_num,
555 line,
556 width = line_num_width
557 ));
558 old_line_num += 1;
559 new_line_num += 1;
560 }
561 if skipped > 0 {
562 out.push(format!(" {:>width$} ...", "", width = line_num_width));
563 old_line_num += skipped;
564 new_line_num += skipped;
565 }
566 } else if has_trailing_change {
567 let skipped = raw_len.saturating_sub(context_lines);
568 if skipped > 0 {
569 out.push(format!(" {:>width$} ...", "", width = line_num_width));
570 old_line_num += skipped;
571 new_line_num += skipped;
572 }
573 for line in &raw[skipped..] {
574 out.push(format!(
575 " {:>width$} {}",
576 old_line_num,
577 line,
578 width = line_num_width
579 ));
580 old_line_num += 1;
581 new_line_num += 1;
582 }
583 } else {
584 old_line_num += raw_len;
586 new_line_num += raw_len;
587 }
588 last_was_change = false;
589 }
590 }
591
592 DiffResult {
593 diff: out.join("\n"),
594 first_changed_line,
595 }
596}
597
598pub fn generate_unified_patch(path: &str, old_content: &str, new_content: &str) -> String {
602 unified_diff(
606 Algorithm::Myers,
607 old_content,
608 new_content,
609 4,
610 Some((path, path)),
611 )
612}
613
614fn empty_old_text_error(path: &str, i: usize, total: usize) -> String {
617 if total == 1 {
618 format!("oldText must not be empty in {path}.")
619 } else {
620 format!("edits[{i}].oldText must not be empty in {path}.")
621 }
622}
623
624fn not_found_error(path: &str, i: usize, total: usize) -> String {
625 if total == 1 {
626 format!("Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines.")
627 } else {
628 format!("Could not find edits[{i}] in {path}. The old text must match exactly including all whitespace and newlines.")
629 }
630}
631
632fn duplicate_error(path: &str, i: usize, total: usize, occurrences: usize) -> String {
633 if total == 1 {
634 format!("Found {occurrences} occurrences of the text in {path}. The text must be unique — include more surrounding context to disambiguate.")
635 } else {
636 format!("Found {occurrences} occurrences of edits[{i}] in {path}. The text must be unique — include more surrounding context to disambiguate.")
637 }
638}
639
640fn no_change_error(path: &str, total: usize) -> String {
641 if total == 1 {
642 format!("No changes made to {path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.")
643 } else {
644 format!("No changes made to {path}. The replacements produced identical content.")
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn normalize_to_lf_collapses_crlf_and_cr() {
654 assert_eq!(normalize_to_lf("a\r\nb\rc\n"), "a\nb\nc\n");
655 }
656
657 #[test]
658 fn detect_line_ending_prefers_crlf_when_first() {
659 assert_eq!(detect_line_ending("a\r\nb\nc"), "\r\n");
660 assert_eq!(detect_line_ending("a\nb\r\nc"), "\n");
661 assert_eq!(detect_line_ending("noslash"), "\n");
662 }
663
664 #[test]
665 fn strip_bom_removes_prefix() {
666 let r = strip_bom("\u{FEFF}hello");
667 assert_eq!(r.bom, "\u{FEFF}");
668 assert_eq!(r.text, "hello");
669 let r2 = strip_bom("hello");
670 assert_eq!(r2.bom, "");
671 assert_eq!(r2.text, "hello");
672 }
673
674 #[test]
675 fn fuzzy_find_exact_then_smart_quotes() {
676 let content = "it's a test";
677 let m = fuzzy_find_text(content, "it's a test");
678 assert!(m.found);
679 assert!(!m.used_fuzzy_match);
680 let m2 = fuzzy_find_text(content, "it\u{2019}s a test");
682 assert!(m2.found);
683 assert!(m2.used_fuzzy_match);
684 }
685
686 #[test]
687 fn apply_edits_single_replacement() {
688 let content = "line1\nline2\nline3\n";
689 let edits = vec![ReplaceEdit {
690 old_text: "line2".to_string(),
691 new_text: "LINE2".to_string(),
692 }];
693 let r = apply_edits_to_normalized_content(content, &edits, "/x").unwrap();
694 assert!(r.new_content.contains("LINE2"));
695 assert!(!r.new_content.contains("line2"));
696 }
697
698 #[test]
699 fn apply_edits_not_found_errors() {
700 let content = "line1\nline2\n";
701 let edits = vec![ReplaceEdit {
702 old_text: "nope".to_string(),
703 new_text: "yep".to_string(),
704 }];
705 let r = apply_edits_to_normalized_content(content, &edits, "/x");
706 assert!(r.is_err());
707 assert_eq!(r.unwrap_err().code, crate::error::FileErrorCode::NotFound);
708 }
709
710 #[test]
711 fn apply_edits_duplicate_errors() {
712 let content = "dup\nmid\ndup\n";
713 let edits = vec![ReplaceEdit {
714 old_text: "dup".to_string(),
715 new_text: "x".to_string(),
716 }];
717 let r = apply_edits_to_normalized_content(content, &edits, "/x");
718 assert!(r.is_err());
719 assert_eq!(r.unwrap_err().code, crate::error::FileErrorCode::Invalid);
720 }
721
722 #[test]
723 fn apply_edits_overlap_errors() {
724 let content = "overlap region here\n";
725 let edits = vec![
726 ReplaceEdit {
727 old_text: "overlap region".to_string(),
728 new_text: "A".to_string(),
729 },
730 ReplaceEdit {
731 old_text: "region here".to_string(),
732 new_text: "B".to_string(),
733 },
734 ];
735 let r = apply_edits_to_normalized_content(content, &edits, "/x");
736 assert!(r.is_err());
737 let msg = r.unwrap_err().to_string();
738 assert!(msg.contains("overlap"));
739 }
740
741 #[test]
742 fn apply_edits_no_change_errors() {
743 let content = "same\n";
744 let edits = vec![ReplaceEdit {
745 old_text: "same".to_string(),
746 new_text: "same".to_string(),
747 }];
748 let r = apply_edits_to_normalized_content(content, &edits, "/x");
749 assert!(r.is_err());
750 let msg = r.unwrap_err().to_string();
751 assert!(msg.contains("No changes"));
752 }
753
754 #[test]
755 fn generate_unified_patch_has_headers() {
756 let patch = generate_unified_patch("/x", "a\nb\n", "a\nB\n");
757 assert!(patch.contains("--- /x"));
758 assert!(patch.contains("+++ /x"));
759 assert!(patch.contains("-b"));
760 assert!(patch.contains("+B"));
761 }
762}