Skip to main content

yapi_core/tools/
edit_diff.rs

1//! Text replacement for `edit`: exact matching with a fuzzy fallback, and the diffs
2//! shown for an edit.
3
4use similar::{ChangeTag, DiffTag, TextDiff};
5use unicode_normalization::UnicodeNormalization;
6
7/// One requested replacement.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Replacement {
10    /// Text to find, which must occur exactly once.
11    pub old_text: String,
12    /// Text to put in its place.
13    pub new_text: String,
14}
15
16/// The line ending a file mostly uses, judged by its first line break.
17pub 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
24/// Converts `\r\n` and lone `\r` to `\n`.
25pub fn normalize_to_lf(text: &str) -> String {
26    text.replace("\r\n", "\n").replace('\r', "\n")
27}
28
29/// `new`, an edit of `normalized`, written over `original`, the text
30/// [`normalize_to_lf`] turned into `normalized`: lines the edit left equal keep
31/// their original bytes and line ending, and changed lines end with `ending`.
32pub fn restore_line_endings(original: &str, normalized: &str, new: &str, ending: &str) -> String {
33    // The line ends `normalize_to_lf` turns into `\n`.
34    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            // `normalized` has one line for each line of `original`.
51            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
64/// The form fuzzy matching compares: Unicode NFKC normalization, no trailing
65/// spaces, ASCII quotes, dashes and spaces.
66pub 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
91/// Finds `old` in `content`, exactly or after fuzzy normalization of both. Returns
92/// the offset and length in the searched text and whether fuzzy matching was used.
93fn 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        // pi's `split("")` yields one piece per UTF-16 unit.
109        return fuzzy_content.encode_utf16().count().saturating_sub(1);
110    }
111    fuzzy_content.matches(&fuzzy_old).count()
112}
113
114/// Applies `edits` to LF-normalized `content`. Every `oldText` is matched against
115/// the original, must be unique and must not overlap another. When fuzzy matching
116/// was needed, lines outside the edited ones keep their original text.
117pub 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
212/// Applies replacements found in the normalized `base` to `original`, copying
213/// unchanged lines from the original.
214fn 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    // Groups of matches by the line range they touch.
234    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
269/// A unified patch with file headers only and 4 lines of context.
270pub 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    // jsdiff writes every range with its count, `-1,1` where GNU style has
281    // `-1`.
282    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
304/// pi's `computeEditsDiff`: the diff `edits` would make to the file at
305/// `path` (resolved against `cwd`), or why they cannot apply. Nothing is
306/// written.
307pub 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
323/// pi's numbered diff for the UI, and the first changed line of the new text.
324pub fn display_diff(old: &str, new: &str) -> (String, Option<usize>) {
325    const CONTEXT: usize = 4;
326    let diff = TextDiff::from_lines(old, new);
327    // Group consecutive changes of one kind, as jsdiff's diffLines parts.
328    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        // Curly quotes and trailing spaces in the file still match, and other
439        // lines keep their original text.
440        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        // Whitespace-only oldText normalizes to "", which pi counts once per UTF-16 unit.
464        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}