1use similar::{ChangeTag, DiffTag, TextDiff};
5use unicode_normalization::UnicodeNormalization;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Replacement {
10 pub old_text: String,
12 pub new_text: String,
14}
15
16pub fn detect_line_ending(content: &str) -> &'static str {
18 match (content.find("\r\n"), content.find('\n')) {
19 (Some(crlf), Some(lf)) if crlf < lf => "\r\n",
20 _ => "\n",
21 }
22}
23
24pub fn normalize_to_lf(text: &str) -> String {
26 text.replace("\r\n", "\n").replace('\r', "\n")
27}
28
29pub fn restore_line_endings(original: &str, normalized: &str, new: &str, ending: &str) -> String {
33 let mut original_lines = Vec::new();
35 let mut rest = original;
36 while let Some(at) = rest.find(['\r', '\n']) {
37 let end = at + if rest[at..].starts_with("\r\n") { 2 } else { 1 };
38 let (line, tail) = rest.split_at(end);
39 original_lines.push(line);
40 rest = tail;
41 }
42 if !rest.is_empty() {
43 original_lines.push(rest);
44 }
45 let diff = TextDiff::from_lines(normalized, new);
46 let mut output = String::with_capacity(new.len());
47 for op in diff.ops() {
48 let (tag, old_lines, new_lines) = op.as_tag_tuple();
49 if tag == DiffTag::Equal {
50 output.extend(original_lines[old_lines].iter().copied());
52 continue;
53 }
54 for line in &diff.new_slices()[new_lines] {
55 match line.strip_suffix('\n') {
56 Some(text) => output.extend([text, ending]),
57 None => output.push_str(line),
58 }
59 }
60 }
61 output
62}
63
64pub fn normalize_for_fuzzy_match(text: &str) -> String {
67 let normalized: String = text.nfkc().collect();
68 normalized
69 .split('\n')
70 .map(str::trim_end)
71 .collect::<Vec<_>>()
72 .join("\n")
73 .chars()
74 .map(|c| match c {
75 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => '\'',
76 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => '"',
77 '\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
78 '\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
79 other => other,
80 })
81 .collect()
82}
83
84struct Match {
85 edit_index: usize,
86 index: usize,
87 length: usize,
88 new_text: String,
89}
90
91fn fuzzy_find(content: &str, old: &str) -> Option<(usize, usize, bool)> {
94 if let Some(index) = content.find(old) {
95 return Some((index, old.len(), false));
96 }
97 let fuzzy_content = normalize_for_fuzzy_match(content);
98 let fuzzy_old = normalize_for_fuzzy_match(old);
99 fuzzy_content
100 .find(&fuzzy_old)
101 .map(|index| (index, fuzzy_old.len(), true))
102}
103
104fn count_occurrences(content: &str, old: &str) -> usize {
105 let fuzzy_content = normalize_for_fuzzy_match(content);
106 let fuzzy_old = normalize_for_fuzzy_match(old);
107 if fuzzy_old.is_empty() {
108 return fuzzy_content.encode_utf16().count().saturating_sub(1);
110 }
111 fuzzy_content.matches(&fuzzy_old).count()
112}
113
114pub fn apply_edits(content: &str, edits: &[Replacement], path: &str) -> Result<String, String> {
118 let total = edits.len();
119 let edits: Vec<Replacement> = edits
120 .iter()
121 .map(|edit| Replacement {
122 old_text: normalize_to_lf(&edit.old_text),
123 new_text: normalize_to_lf(&edit.new_text),
124 })
125 .collect();
126 for (index, edit) in edits.iter().enumerate() {
127 if edit.old_text.is_empty() {
128 return Err(if total == 1 {
129 format!("oldText must not be empty in {path}.")
130 } else {
131 format!("edits[{index}].oldText must not be empty in {path}.")
132 });
133 }
134 }
135 let used_fuzzy = edits
136 .iter()
137 .any(|edit| fuzzy_find(content, &edit.old_text).is_some_and(|(_, _, fuzzy)| fuzzy));
138 let base = if used_fuzzy {
139 normalize_for_fuzzy_match(content)
140 } else {
141 content.to_owned()
142 };
143
144 let mut matches = Vec::new();
145 for (index, edit) in edits.iter().enumerate() {
146 let Some((at, length, _)) = fuzzy_find(&base, &edit.old_text) else {
147 return Err(if total == 1 {
148 format!(
149 "Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines."
150 )
151 } else {
152 format!(
153 "Could not find edits[{index}] in {path}. The oldText must match exactly including all whitespace and newlines."
154 )
155 });
156 };
157 let occurrences = count_occurrences(&base, &edit.old_text);
158 if occurrences > 1 {
159 return Err(if total == 1 {
160 format!(
161 "Found {occurrences} occurrences of the text in {path}. The text must be unique. Please provide more context to make it unique."
162 )
163 } else {
164 format!(
165 "Found {occurrences} occurrences of edits[{index}] in {path}. Each oldText must be unique. Please provide more context to make it unique."
166 )
167 });
168 }
169 matches.push(Match {
170 edit_index: index,
171 index: at,
172 length,
173 new_text: edit.new_text.clone(),
174 });
175 }
176 matches.sort_by_key(|m| m.index);
177 for pair in matches.windows(2) {
178 if pair[0].index + pair[0].length > pair[1].index {
179 return Err(format!(
180 "edits[{}] and edits[{}] overlap in {path}. Merge them into one edit or target disjoint regions.",
181 pair[0].edit_index, pair[1].edit_index
182 ));
183 }
184 }
185
186 let new_content = if used_fuzzy {
187 replace_preserving_lines(content, &base, &matches)?
188 } else {
189 replace(&base, &matches, 0)
190 };
191 if new_content == content {
192 return Err(if total == 1 {
193 format!(
194 "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."
195 )
196 } else {
197 format!("No changes made to {path}. The replacements produced identical content.")
198 });
199 }
200 Ok(new_content)
201}
202
203fn replace<M: std::borrow::Borrow<Match>>(content: &str, matches: &[M], offset: usize) -> String {
204 let mut result = content.to_owned();
205 for m in matches.iter().rev().map(M::borrow) {
206 let start = m.index - offset;
207 result.replace_range(start..start + m.length, &m.new_text);
208 }
209 result
210}
211
212fn replace_preserving_lines(
215 original: &str,
216 base: &str,
217 matches: &[Match],
218) -> Result<String, String> {
219 let original_lines: Vec<&str> = original.split_inclusive('\n').collect();
220 let mut spans = Vec::new();
221 let mut offset = 0;
222 for line in base.split_inclusive('\n') {
223 spans.push((offset, offset + line.len()));
224 offset += line.len();
225 }
226 if original_lines.len() != spans.len() {
227 return Err(
228 "Cannot preserve unchanged lines because the base content has a different line count."
229 .into(),
230 );
231 }
232 let outside = || "Replacement range is outside the base content.".to_owned();
233 let mut groups: Vec<(usize, usize, Vec<&Match>)> = Vec::new();
235 for m in matches {
236 let start_line = spans
237 .iter()
238 .position(|(start, end)| m.index >= *start && m.index < *end)
239 .ok_or_else(outside)?;
240 let mut end_line = start_line;
241 while end_line < spans.len() && spans[end_line].1 < m.index + m.length {
242 end_line += 1;
243 }
244 if end_line >= spans.len() {
245 return Err(outside());
246 }
247 let end_line = end_line + 1;
248 match groups.last_mut() {
249 Some(group) if start_line < group.1 => {
250 group.1 = group.1.max(end_line);
251 group.2.push(m);
252 }
253 _ => groups.push((start_line, end_line, vec![m])),
254 }
255 }
256 let mut result = String::new();
257 let mut line = 0;
258 for (start, end, group) in groups {
259 result.push_str(&original_lines[line..start].concat());
260 let from = spans[start].0;
261 let to = spans[end - 1].1;
262 result += &replace(&base[from..to], &group, from);
263 line = end;
264 }
265 result.push_str(&original_lines[line..].concat());
266 Ok(result)
267}
268
269pub fn unified_patch(path: &str, old: &str, new: &str) -> String {
271 let diff = TextDiff::from_lines(old, new);
272 let output = diff
273 .unified_diff()
274 .context_radius(4)
275 .header(path, path)
276 .to_string();
277 if output.is_empty() {
278 return format!("--- {path}\n+++ {path}\n");
279 }
280 output
283 .split_inclusive('\n')
284 .map(|line| match line.strip_prefix("@@ ") {
285 Some(rest) => {
286 let ranges: Vec<String> = rest
287 .split_whitespace()
288 .take_while(|part| *part != "@@")
289 .map(|range| {
290 if range.contains(',') {
291 range.to_owned()
292 } else {
293 format!("{range},1")
294 }
295 })
296 .collect();
297 format!("@@ {} @@\n", ranges.join(" "))
298 }
299 None => line.to_owned(),
300 })
301 .collect()
302}
303
304pub fn preview_edits(
308 path: &str,
309 edits: &[Replacement],
310 cwd: &std::path::Path,
311) -> Result<String, String> {
312 let absolute = super::path::resolve_to_cwd(path, cwd);
313 let raw = std::fs::read(&absolute)
314 .map_err(|err| format!("Could not edit file: {path}. {}.", super::error_code(&err)))?;
315 let raw = String::from_utf8(raw)
316 .map_err(|_| format!("Could not edit file: {path}. File is not valid UTF-8."))?;
317 let content = raw.strip_prefix('\u{FEFF}').unwrap_or(&raw);
318 let normalized = normalize_to_lf(content);
319 let new_content = apply_edits(&normalized, edits, path)?;
320 Ok(display_diff(&normalized, &new_content).0)
321}
322
323pub fn display_diff(old: &str, new: &str) -> (String, Option<usize>) {
325 const CONTEXT: usize = 4;
326 let diff = TextDiff::from_lines(old, new);
327 let mut parts: Vec<(ChangeTag, Vec<String>)> = Vec::new();
329 for change in diff.iter_all_changes() {
330 let line = change.value().trim_end_matches('\n').to_owned();
331 match parts.last_mut() {
332 Some((tag, lines)) if *tag == change.tag() => lines.push(line),
333 _ => parts.push((change.tag(), vec![line])),
334 }
335 }
336 let width = old
337 .split('\n')
338 .count()
339 .max(new.split('\n').count())
340 .to_string()
341 .len();
342 let pad = |n: usize| format!("{n:>width$}");
343 let blank = " ".repeat(width);
344 let mut output = Vec::new();
345 let (mut old_line, mut new_line) = (1usize, 1usize);
346 let mut last_was_change = false;
347 let mut first_changed = None;
348 for (index, (tag, lines)) in parts.iter().enumerate() {
349 match tag {
350 ChangeTag::Insert | ChangeTag::Delete => {
351 first_changed.get_or_insert(new_line);
352 for line in lines {
353 if *tag == ChangeTag::Insert {
354 output.push(format!("+{} {line}", pad(new_line)));
355 new_line += 1;
356 } else {
357 output.push(format!("-{} {line}", pad(old_line)));
358 old_line += 1;
359 }
360 }
361 last_was_change = true;
362 }
363 ChangeTag::Equal => {
364 let next_is_change = parts
365 .get(index + 1)
366 .is_some_and(|(next, _)| *next != ChangeTag::Equal);
367 let show = |lines: &[String],
368 output: &mut Vec<String>,
369 old: &mut usize,
370 new: &mut usize| {
371 for line in lines {
372 output.push(format!(" {} {line}", pad(*old)));
373 *old += 1;
374 *new += 1;
375 }
376 };
377 if last_was_change && next_is_change {
378 if lines.len() <= CONTEXT * 2 {
379 show(lines, &mut output, &mut old_line, &mut new_line);
380 } else {
381 show(&lines[..CONTEXT], &mut output, &mut old_line, &mut new_line);
382 output.push(format!(" {blank} ..."));
383 let skipped = lines.len() - CONTEXT * 2;
384 old_line += skipped;
385 new_line += skipped;
386 show(
387 &lines[lines.len() - CONTEXT..],
388 &mut output,
389 &mut old_line,
390 &mut new_line,
391 );
392 }
393 } else if last_was_change {
394 let shown = lines.len().min(CONTEXT);
395 show(&lines[..shown], &mut output, &mut old_line, &mut new_line);
396 let skipped = lines.len() - shown;
397 if skipped > 0 {
398 output.push(format!(" {blank} ..."));
399 old_line += skipped;
400 new_line += skipped;
401 }
402 } else if next_is_change {
403 let skipped = lines.len().saturating_sub(CONTEXT);
404 if skipped > 0 {
405 output.push(format!(" {blank} ..."));
406 old_line += skipped;
407 new_line += skipped;
408 }
409 show(&lines[skipped..], &mut output, &mut old_line, &mut new_line);
410 } else {
411 old_line += lines.len();
412 new_line += lines.len();
413 }
414 last_was_change = false;
415 }
416 }
417 }
418 (output.join("\n"), first_changed)
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn edit(old: &str, new: &str) -> Replacement {
426 Replacement {
427 old_text: old.into(),
428 new_text: new.into(),
429 }
430 }
431
432 #[test]
433 fn exact_and_fuzzy_matches() {
434 assert_eq!(
435 apply_edits("a\nb\nc\n", &[edit("b", "B"), edit("c", "C")], "f").unwrap(),
436 "a\nB\nC\n"
437 );
438 let content = "say \u{201C}hi\u{201D} \nkeep\u{2019}s\n";
441 assert_eq!(
442 apply_edits(content, &[edit("say \"hi\"", "say \"bye\"")], "f").unwrap(),
443 "say \"bye\"\nkeep\u{2019}s\n"
444 );
445 }
446
447 #[test]
448 fn reports_pi_errors() {
449 assert_eq!(
450 apply_edits("aa", &[edit("a", "b")], "f").unwrap_err(),
451 "Found 2 occurrences of the text in f. The text must be unique. Please provide more context to make it unique."
452 );
453 assert!(
454 apply_edits("abc", &[edit("ab", "x"), edit("bc", "y")], "f")
455 .unwrap_err()
456 .contains("overlap")
457 );
458 assert!(
459 apply_edits("abc", &[edit("zz", "x")], "f")
460 .unwrap_err()
461 .starts_with("Could not find the exact text in f.")
462 );
463 assert_eq!(
465 apply_edits("a\tb\tc", &[edit("\t", "-")], "f").unwrap_err(),
466 "Found 4 occurrences of the text in f. The text must be unique. Please provide more context to make it unique."
467 );
468 assert_eq!(
469 apply_edits("abc", &[edit("\t", "-")], "f").unwrap_err(),
470 "Found 2 occurrences of the text in f. The text must be unique. Please provide more context to make it unique."
471 );
472 }
473
474 #[test]
475 fn unchanged_line_endings() {
476 let restore = |original: &str, new: &str, ending| {
477 restore_line_endings(original, &normalize_to_lf(original), new, ending)
478 };
479 assert_eq!(
480 restore("a\r\nb\r\nc", "a\nB\nB2\nc", "\r\n"),
481 "a\r\nB\r\nB2\r\nc"
482 );
483 assert_eq!(restore("x\r\r\ny\n", "x\n\nY\n", "\n"), "x\r\r\nY\n");
484 assert_eq!(restore("a\r\nb", "a\nb\nc", "\r\n"), "a\r\nb\r\nc");
485 }
486
487 #[test]
488 fn diffs_with_line_numbers() {
489 let (diff, first) = display_diff("a\nb\nc\n", "a\nB\nc\n");
490 assert_eq!(diff, " 1 a\n-2 b\n+2 B\n 3 c");
491 assert_eq!(first, Some(2));
492 }
493
494 #[test]
495 fn patch_range_counts() {
496 assert_eq!(
497 unified_patch("a.txt", "x\n", "y\n"),
498 "--- a.txt\n+++ a.txt\n@@ -1,1 +1,1 @@\n-x\n+y\n"
499 );
500 assert!(unified_patch("a.txt", "a\nb\n", "a\nc\nd\n").contains("@@ -1,2 +1,3 @@\n"));
501 assert!(unified_patch("a.txt", "", "a\n").contains("@@ -0,0 +1,1 @@\n"));
502 }
503}