Skip to main content

pi/core/tools/
edit_diff.rs

1//! Pure edit matching and diff helpers for the edit tool.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/tools/edit-diff.ts`.
4//! Matching runs against the original LF-normalized file; multi-edits apply in
5//! reverse offset order. Fuzzy matches preserve untouched original line bytes.
6
7use serde::{Deserialize, Serialize};
8use unicode_normalization::UnicodeNormalization;
9
10/// Line ending detected from file content (first occurrence wins).
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum LineEnding {
13    /// Unix LF.
14    Lf,
15    /// Windows CRLF.
16    Crlf,
17}
18
19impl LineEnding {
20    /// Returns the ending bytes as a string.
21    #[must_use]
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Lf => "\n",
25            Self::Crlf => "\r\n",
26        }
27    }
28}
29
30/// One exact-text replacement.
31#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Edit {
34    /// Text to find in the original file.
35    pub old_text: String,
36    /// Replacement text.
37    pub new_text: String,
38}
39
40/// Result of applying edits to LF-normalized content.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct AppliedEditsResult {
43    /// Content used as the left-hand side of display/unified diffs (original LF).
44    pub base_content: String,
45    /// Content after all replacements (still LF-normalized).
46    pub new_content: String,
47}
48
49/// Display-oriented diff result.
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct DiffStringResult {
52    /// Numbered context diff text.
53    pub diff: String,
54    /// First changed line number in the new file (1-based), if any.
55    pub first_changed_line: Option<usize>,
56}
57
58/// Detect line ending from the first newline occurrence.
59#[must_use]
60pub fn detect_line_ending(content: &str) -> LineEnding {
61    let crlf_idx = content.find("\r\n");
62    let lf_idx = content.find('\n');
63    match (crlf_idx, lf_idx) {
64        (None, None | Some(_)) => LineEnding::Lf,
65        (Some(_), None) => LineEnding::Crlf,
66        (Some(crlf), Some(lf)) => {
67            if crlf < lf {
68                LineEnding::Crlf
69            } else {
70                LineEnding::Lf
71            }
72        }
73    }
74}
75
76/// Normalize `\r\n` and bare `\r` to `\n`.
77#[must_use]
78pub fn normalize_to_lf(text: &str) -> String {
79    text.replace("\r\n", "\n").replace('\r', "\n")
80}
81
82/// Restore LF content to the original line ending style.
83#[must_use]
84pub fn restore_line_endings(text: &str, ending: LineEnding) -> String {
85    match ending {
86        LineEnding::Lf => text.to_owned(),
87        LineEnding::Crlf => text.replace('\n', "\r\n"),
88    }
89}
90
91/// Strip a leading UTF-8 BOM, returning `(bom, text_without_bom)`.
92///
93/// `bom` is `"\u{FEFF}"` when present, otherwise empty.
94#[must_use]
95pub fn strip_bom(content: &str) -> (String, String) {
96    if let Some(rest) = content.strip_prefix('\u{FEFF}') {
97        ("\u{FEFF}".to_owned(), rest.to_owned())
98    } else {
99        (String::new(), content.to_owned())
100    }
101}
102
103/// Normalize text for fuzzy matching (NFKC, trimEnd, quotes, dashes, spaces).
104#[must_use]
105pub fn normalize_for_fuzzy_match(text: &str) -> String {
106    let nfkc: String = text.nfkc().collect();
107    let trimmed: String = nfkc
108        .split('\n')
109        .map(str::trim_end)
110        .collect::<Vec<_>>()
111        .join("\n");
112    let mut out = String::with_capacity(trimmed.len());
113    for ch in trimmed.chars() {
114        match ch {
115            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => out.push('\''),
116            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
117            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
118            | '\u{2212}' => out.push('-'),
119            '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
120            | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
121            | '\u{3000}' => out.push(' '),
122            other => out.push(other),
123        }
124    }
125    out
126}
127
128#[derive(Clone, Debug)]
129struct FuzzyMatchResult {
130    found: bool,
131    index: usize,
132    match_length: usize,
133    used_fuzzy_match: bool,
134}
135
136fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
137    if let Some(exact_index) = content.find(old_text) {
138        return FuzzyMatchResult {
139            found: true,
140            index: exact_index,
141            match_length: old_text.len(),
142            used_fuzzy_match: false,
143        };
144    }
145
146    let fuzzy_content = normalize_for_fuzzy_match(content);
147    let fuzzy_old = normalize_for_fuzzy_match(old_text);
148    if let Some(fuzzy_index) = fuzzy_content.find(&fuzzy_old) {
149        return FuzzyMatchResult {
150            found: true,
151            index: fuzzy_index,
152            match_length: fuzzy_old.len(),
153            used_fuzzy_match: true,
154        };
155    }
156
157    FuzzyMatchResult {
158        found: false,
159        index: 0,
160        match_length: 0,
161        used_fuzzy_match: false,
162    }
163}
164
165/// Count non-overlapping occurrences after fuzzy normalization (JS `split.length - 1`).
166fn count_occurrences(content: &str, old_text: &str) -> usize {
167    let fuzzy_content = normalize_for_fuzzy_match(content);
168    let fuzzy_old = normalize_for_fuzzy_match(old_text);
169    if fuzzy_old.is_empty() {
170        return 0;
171    }
172    fuzzy_content
173        .split(fuzzy_old.as_str())
174        .count()
175        .saturating_sub(1)
176}
177
178fn empty_old_text_error(path: &str, edit_index: usize, total_edits: usize) -> String {
179    if total_edits == 1 {
180        format!("oldText must not be empty in {path}.")
181    } else {
182        format!("edits[{edit_index}].oldText must not be empty in {path}.")
183    }
184}
185
186fn not_found_error(path: &str, edit_index: usize, total_edits: usize) -> String {
187    if total_edits == 1 {
188        format!(
189            "Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines."
190        )
191    } else {
192        format!(
193            "Could not find edits[{edit_index}] in {path}. The oldText must match exactly including all whitespace and newlines."
194        )
195    }
196}
197
198fn duplicate_error(
199    path: &str,
200    edit_index: usize,
201    total_edits: usize,
202    occurrences: usize,
203) -> String {
204    if total_edits == 1 {
205        format!(
206            "Found {occurrences} occurrences of the text in {path}. The text must be unique. Please provide more context to make it unique."
207        )
208    } else {
209        format!(
210            "Found {occurrences} occurrences of edits[{edit_index}] in {path}. Each oldText must be unique. Please provide more context to make it unique."
211        )
212    }
213}
214
215fn no_change_error(path: &str, total_edits: usize) -> String {
216    if total_edits == 1 {
217        format!(
218            "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."
219        )
220    } else {
221        format!("No changes made to {path}. The replacements produced identical content.")
222    }
223}
224
225#[derive(Clone, Debug)]
226struct MatchedEdit {
227    edit_index: usize,
228    match_index: usize,
229    match_length: usize,
230    new_text: String,
231}
232
233#[derive(Clone, Copy, Debug)]
234struct LineSpan {
235    start: usize,
236    end: usize,
237}
238
239fn split_lines_with_endings(content: &str) -> Vec<String> {
240    if content.is_empty() {
241        return Vec::new();
242    }
243    let mut lines = Vec::new();
244    let mut start = 0usize;
245    let bytes = content.as_bytes();
246    for (idx, byte) in bytes.iter().enumerate() {
247        if *byte == b'\n' {
248            lines.push(content[start..=idx].to_owned());
249            start = idx + 1;
250        }
251    }
252    if start < content.len() {
253        lines.push(content[start..].to_owned());
254    }
255    lines
256}
257
258fn get_line_spans(content: &str) -> Vec<LineSpan> {
259    let mut offset = 0usize;
260    split_lines_with_endings(content)
261        .into_iter()
262        .map(|line| {
263            let span = LineSpan {
264                start: offset,
265                end: offset + line.len(),
266            };
267            offset = span.end;
268            span
269        })
270        .collect()
271}
272
273fn get_replacement_line_range(
274    lines: &[LineSpan],
275    match_index: usize,
276    match_length: usize,
277) -> Result<(usize, usize), String> {
278    let replacement_start = match_index;
279    let replacement_end = match_index + match_length;
280
281    let mut start_line = None;
282    for (i, line) in lines.iter().enumerate() {
283        if replacement_start >= line.start && replacement_start < line.end {
284            start_line = Some(i);
285            break;
286        }
287    }
288    let start_line =
289        start_line.ok_or_else(|| "Replacement range is outside the base content.".to_owned())?;
290
291    let mut end_line = start_line;
292    while end_line < lines.len() && lines[end_line].end < replacement_end {
293        end_line += 1;
294    }
295    if end_line >= lines.len() {
296        return Err("Replacement range is outside the base content.".to_owned());
297    }
298    Ok((start_line, end_line + 1))
299}
300
301fn apply_replacements(content: &str, replacements: &[MatchedEdit], offset: usize) -> String {
302    let mut result = content.to_owned();
303    for replacement in replacements.iter().rev() {
304        let match_index = replacement.match_index.saturating_sub(offset);
305        let end = match_index + replacement.match_length;
306        if match_index > result.len() || end > result.len() {
307            continue;
308        }
309        result.replace_range(match_index..end, &replacement.new_text);
310    }
311    result
312}
313
314#[derive(Clone, Debug)]
315struct ReplacementGroup {
316    start_line: usize,
317    end_line: usize,
318    replacements: Vec<MatchedEdit>,
319}
320
321/// Apply replacements matched against `base_content` onto `original_content`,
322/// copying untouched original line blocks verbatim.
323fn apply_replacements_preserving_unchanged_lines(
324    original_content: &str,
325    base_content: &str,
326    replacements: &[MatchedEdit],
327) -> Result<String, String> {
328    let original_lines = split_lines_with_endings(original_content);
329    let base_lines = get_line_spans(base_content);
330    if original_lines.len() != base_lines.len() {
331        return Err(
332            "Cannot preserve unchanged lines because the base content has a different line count."
333                .to_owned(),
334        );
335    }
336
337    let mut groups: Vec<ReplacementGroup> = Vec::new();
338    let mut sorted = replacements.to_vec();
339    sorted.sort_by_key(|item| item.match_index);
340    for replacement in sorted {
341        let (start_line, end_line) = get_replacement_line_range(
342            &base_lines,
343            replacement.match_index,
344            replacement.match_length,
345        )?;
346        if let Some(current) = groups.last_mut()
347            && start_line < current.end_line
348        {
349            current.end_line = current.end_line.max(end_line);
350            current.replacements.push(replacement);
351            continue;
352        }
353        groups.push(ReplacementGroup {
354            start_line,
355            end_line,
356            replacements: vec![replacement],
357        });
358    }
359
360    let mut original_line_index = 0usize;
361    let mut result = String::new();
362    for group in groups {
363        for line in &original_lines[original_line_index..group.start_line] {
364            result.push_str(line);
365        }
366        let group_start_offset = base_lines[group.start_line].start;
367        let group_end_offset = base_lines[group.end_line - 1].end;
368        let slice = &base_content[group_start_offset..group_end_offset];
369        result.push_str(&apply_replacements(
370            slice,
371            &group.replacements,
372            group_start_offset,
373        ));
374        original_line_index = group.end_line;
375    }
376    for line in &original_lines[original_line_index..] {
377        result.push_str(line);
378    }
379    Ok(result)
380}
381
382/// Apply one or more exact-text replacements to LF-normalized content.
383///
384/// # Errors
385///
386/// Returns an error string matching TypeScript edit-diff messages for empty
387/// oldText, not found, duplicate, overlap, and no-op cases.
388pub fn apply_edits_to_normalized_content(
389    normalized_content: &str,
390    edits: &[Edit],
391    path: &str,
392) -> Result<AppliedEditsResult, String> {
393    let normalized_edits: Vec<Edit> = edits
394        .iter()
395        .map(|edit| Edit {
396            old_text: normalize_to_lf(&edit.old_text),
397            new_text: normalize_to_lf(&edit.new_text),
398        })
399        .collect();
400
401    let total = normalized_edits.len();
402    for (i, edit) in normalized_edits.iter().enumerate() {
403        if edit.old_text.is_empty() {
404            return Err(empty_old_text_error(path, i, total));
405        }
406    }
407
408    let initial_matches: Vec<FuzzyMatchResult> = normalized_edits
409        .iter()
410        .map(|edit| fuzzy_find_text(normalized_content, &edit.old_text))
411        .collect();
412    let used_fuzzy_match = initial_matches.iter().any(|m| m.used_fuzzy_match);
413    let replacement_base_content = if used_fuzzy_match {
414        normalize_for_fuzzy_match(normalized_content)
415    } else {
416        normalized_content.to_owned()
417    };
418
419    let mut matched_edits: Vec<MatchedEdit> = Vec::with_capacity(total);
420    for (i, edit) in normalized_edits.iter().enumerate() {
421        let match_result = fuzzy_find_text(&replacement_base_content, &edit.old_text);
422        if !match_result.found {
423            return Err(not_found_error(path, i, total));
424        }
425        let occurrences = count_occurrences(&replacement_base_content, &edit.old_text);
426        if occurrences > 1 {
427            return Err(duplicate_error(path, i, total, occurrences));
428        }
429        matched_edits.push(MatchedEdit {
430            edit_index: i,
431            match_index: match_result.index,
432            match_length: match_result.match_length,
433            new_text: edit.new_text.clone(),
434        });
435    }
436
437    matched_edits.sort_by_key(|item| item.match_index);
438    for i in 1..matched_edits.len() {
439        let previous = &matched_edits[i - 1];
440        let current = &matched_edits[i];
441        if previous.match_index + previous.match_length > current.match_index {
442            return Err(format!(
443                "edits[{}] and edits[{}] overlap in {path}. Merge them into one edit or target disjoint regions.",
444                previous.edit_index, current.edit_index
445            ));
446        }
447    }
448
449    let base_content = normalized_content.to_owned();
450    let new_content = if used_fuzzy_match {
451        apply_replacements_preserving_unchanged_lines(
452            normalized_content,
453            &replacement_base_content,
454            &matched_edits,
455        )?
456    } else {
457        apply_replacements(&replacement_base_content, &matched_edits, 0)
458    };
459
460    if base_content == new_content {
461        return Err(no_change_error(path, total));
462    }
463
464    Ok(AppliedEditsResult {
465        base_content,
466        new_content,
467    })
468}
469
470fn split_lines_keep_trailing_empty(content: &str) -> Vec<&str> {
471    content.split('\n').collect()
472}
473
474fn split_patch_lines(content: &str) -> Vec<&str> {
475    if content.is_empty() {
476        Vec::new()
477    } else {
478        content.split_terminator('\n').collect()
479    }
480}
481
482#[derive(Clone, Debug)]
483struct PatchLine {
484    prefix: char,
485    text: String,
486}
487
488impl PatchLine {
489    const fn is_context(&self) -> bool {
490        self.prefix == ' '
491    }
492
493    fn old_increment(&self) -> usize {
494        usize::from(self.prefix != '+')
495    }
496
497    fn new_increment(&self) -> usize {
498        usize::from(self.prefix != '-')
499    }
500}
501
502fn patch_lines(old_content: &str, new_content: &str) -> Vec<PatchLine> {
503    let old_lines = split_patch_lines(old_content);
504    let new_lines = split_patch_lines(new_content);
505    let lcs = longest_common_subsequence(&old_lines, &new_lines);
506    let mut ops = Vec::new();
507    let mut old_index = 0;
508    let mut new_index = 0;
509    let mut common_index = 0;
510
511    while old_index < old_lines.len() || new_index < new_lines.len() {
512        if common_index < lcs.len()
513            && old_index == lcs[common_index].0
514            && new_index == lcs[common_index].1
515        {
516            ops.push(Op::Equal(old_lines[old_index].to_owned()));
517            old_index += 1;
518            new_index += 1;
519            common_index += 1;
520        } else if new_index < new_lines.len()
521            && (common_index >= lcs.len() || new_index < lcs[common_index].1)
522        {
523            ops.push(Op::Added(new_lines[new_index].to_owned()));
524            new_index += 1;
525        } else if old_index < old_lines.len() {
526            ops.push(Op::Removed(old_lines[old_index].to_owned()));
527            old_index += 1;
528        }
529    }
530
531    let mut lines = Vec::new();
532    for part in collapse_ops(&ops) {
533        match part {
534            DiffPart::Equal(equal) => lines.extend(
535                equal
536                    .into_iter()
537                    .map(|text| PatchLine { prefix: ' ', text }),
538            ),
539            DiffPart::Change { removed, added } => {
540                lines.extend(
541                    removed
542                        .into_iter()
543                        .map(|text| PatchLine { prefix: '-', text }),
544                );
545                lines.extend(
546                    added
547                        .into_iter()
548                        .map(|text| PatchLine { prefix: '+', text }),
549                );
550            }
551        }
552    }
553    lines
554}
555
556fn context_boundary(
557    lines: &[PatchLine],
558    from: usize,
559    context_lines: usize,
560    reverse: bool,
561) -> usize {
562    let mut index = from;
563    let mut context = 0;
564    while if reverse {
565        index > 0
566    } else {
567        index < lines.len()
568    } {
569        let candidate = if reverse { index - 1 } else { index };
570        if lines[candidate].is_context() {
571            if context == context_lines {
572                break;
573            }
574            context += 1;
575        }
576        index = if reverse { candidate } else { candidate + 1 };
577    }
578    index
579}
580
581/// Generate a standard contextual unified patch.
582#[must_use]
583pub fn generate_unified_patch(
584    path: &str,
585    old_content: &str,
586    new_content: &str,
587    context_lines: usize,
588) -> String {
589    let mut out = format!("--- {path}\n+++ {path}\n");
590    if old_content == new_content {
591        return out;
592    }
593
594    let lines = patch_lines(old_content, new_content);
595    let changes: Vec<usize> = lines
596        .iter()
597        .enumerate()
598        .filter_map(|(index, line)| (!line.is_context()).then_some(index))
599        .collect();
600    let mut groups = Vec::<(usize, usize)>::new();
601    let mut first = changes[0];
602    let mut last = first;
603    for &change in &changes[1..] {
604        let unchanged_between = lines[last + 1..change]
605            .iter()
606            .filter(|line| line.is_context())
607            .count();
608        if unchanged_between > context_lines.saturating_mul(2) {
609            groups.push((first, last));
610            first = change;
611        }
612        last = change;
613    }
614    groups.push((first, last));
615
616    for (first_change, last_change) in groups {
617        let start = context_boundary(&lines, first_change, context_lines, true);
618        let end = context_boundary(&lines, last_change + 1, context_lines, false);
619        let old_before: usize = lines[..start].iter().map(PatchLine::old_increment).sum();
620        let new_before: usize = lines[..start].iter().map(PatchLine::new_increment).sum();
621        let old_count: usize = lines[start..end].iter().map(PatchLine::old_increment).sum();
622        let new_count: usize = lines[start..end].iter().map(PatchLine::new_increment).sum();
623        let old_start = if old_count == 0 {
624            old_before
625        } else {
626            old_before + 1
627        };
628        let new_start = if new_count == 0 {
629            new_before
630        } else {
631            new_before + 1
632        };
633        out.push_str("@@ -");
634        out.push_str(&old_start.to_string());
635        out.push(',');
636        out.push_str(&old_count.to_string());
637        out.push_str(" +");
638        out.push_str(&new_start.to_string());
639        out.push(',');
640        out.push_str(&new_count.to_string());
641        out.push_str(" @@\n");
642        for line in &lines[start..end] {
643            out.push(line.prefix);
644            out.push_str(&line.text);
645            out.push('\n');
646        }
647    }
648    out
649}
650
651/// Generate a display-oriented numbered diff with context collapse.
652#[must_use]
653pub fn generate_diff_string(
654    old_content: &str,
655    new_content: &str,
656    context_lines: usize,
657) -> DiffStringResult {
658    let old_lines = split_lines_keep_trailing_empty(old_content);
659    let new_lines = split_lines_keep_trailing_empty(new_content);
660    let line_num_width = old_lines
661        .len()
662        .max(new_lines.len())
663        .max(1)
664        .to_string()
665        .len();
666    let parts = collapse_ops(&diff_ops(&old_lines, &new_lines));
667    render_diff_parts(&parts, context_lines, line_num_width)
668}
669
670fn diff_ops(old_lines: &[&str], new_lines: &[&str]) -> Vec<Op> {
671    let lcs = longest_common_subsequence(old_lines, new_lines);
672    let mut ops = Vec::new();
673    let mut old_index = 0;
674    let mut new_index = 0;
675    let mut common_index = 0;
676    while old_index < old_lines.len() || new_index < new_lines.len() {
677        if common_index < lcs.len()
678            && old_index < old_lines.len()
679            && new_index < new_lines.len()
680            && old_index == lcs[common_index].0
681            && new_index == lcs[common_index].1
682        {
683            ops.push(Op::Equal(old_lines[old_index].to_owned()));
684            old_index += 1;
685            new_index += 1;
686            common_index += 1;
687        } else if new_index < new_lines.len()
688            && (common_index >= lcs.len() || new_index < lcs[common_index].1)
689        {
690            ops.push(Op::Added(new_lines[new_index].to_owned()));
691            new_index += 1;
692        } else if old_index < old_lines.len()
693            && (common_index >= lcs.len() || old_index < lcs[common_index].0)
694        {
695            ops.push(Op::Removed(old_lines[old_index].to_owned()));
696            old_index += 1;
697        } else {
698            break;
699        }
700    }
701    ops
702}
703
704struct DiffRenderState {
705    output: Vec<String>,
706    old_line_num: usize,
707    new_line_num: usize,
708    last_was_change: bool,
709    first_changed_line: Option<usize>,
710}
711
712impl DiffRenderState {
713    fn new() -> Self {
714        Self {
715            output: Vec::new(),
716            old_line_num: 1,
717            new_line_num: 1,
718            last_was_change: false,
719            first_changed_line: None,
720        }
721    }
722
723    fn push_numbered(&mut self, prefix: char, line: &str, width: usize) {
724        let line_num = if prefix == '+' {
725            self.new_line_num
726        } else {
727            self.old_line_num
728        };
729        self.output
730            .push(format!("{prefix}{line_num:>width$} {line}"));
731    }
732
733    fn advance_both(&mut self, count: usize) {
734        self.old_line_num += count;
735        self.new_line_num += count;
736    }
737
738    fn push_gap(&mut self, width: usize) {
739        self.output.push(format!(" {:>width$} ...", ""));
740    }
741}
742
743fn render_diff_parts(
744    parts: &[DiffPart],
745    context_lines: usize,
746    line_num_width: usize,
747) -> DiffStringResult {
748    let mut state = DiffRenderState::new();
749    for (part_index, part) in parts.iter().enumerate() {
750        match part {
751            DiffPart::Change { added, removed } => {
752                state.first_changed_line.get_or_insert(state.new_line_num);
753                for line in removed {
754                    state.push_numbered('-', line, line_num_width);
755                    state.old_line_num += 1;
756                }
757                for line in added {
758                    state.push_numbered('+', line, line_num_width);
759                    state.new_line_num += 1;
760                }
761                state.last_was_change = true;
762            }
763            DiffPart::Equal(lines) => {
764                let next_is_change = parts
765                    .get(part_index + 1)
766                    .is_some_and(|part| matches!(part, DiffPart::Change { .. }));
767                render_equal_part(
768                    &mut state,
769                    lines,
770                    context_lines,
771                    line_num_width,
772                    next_is_change,
773                );
774                state.last_was_change = false;
775            }
776        }
777    }
778    DiffStringResult {
779        diff: state.output.join("\n"),
780        first_changed_line: state.first_changed_line,
781    }
782}
783
784fn render_equal_part(
785    state: &mut DiffRenderState,
786    lines: &[String],
787    context_lines: usize,
788    line_num_width: usize,
789    next_is_change: bool,
790) {
791    match (state.last_was_change, next_is_change) {
792        (true, true) if lines.len() <= context_lines * 2 => {
793            push_context_lines(state, lines, line_num_width);
794        }
795        (true, true) => {
796            push_context_lines(state, &lines[..context_lines], line_num_width);
797            let skipped = lines.len() - context_lines * 2;
798            state.push_gap(line_num_width);
799            state.advance_both(skipped);
800            push_context_lines(state, &lines[lines.len() - context_lines..], line_num_width);
801        }
802        (true, false) => {
803            let shown = lines.len().min(context_lines);
804            push_context_lines(state, &lines[..shown], line_num_width);
805            let skipped = lines.len() - shown;
806            if skipped > 0 {
807                state.push_gap(line_num_width);
808                state.advance_both(skipped);
809            }
810        }
811        (false, true) => {
812            let skipped = lines.len().saturating_sub(context_lines);
813            if skipped > 0 {
814                state.push_gap(line_num_width);
815                state.advance_both(skipped);
816            }
817            push_context_lines(state, &lines[skipped..], line_num_width);
818        }
819        (false, false) => state.advance_both(lines.len()),
820    }
821}
822
823fn push_context_lines(state: &mut DiffRenderState, lines: &[String], line_num_width: usize) {
824    for line in lines {
825        state.push_numbered(' ', line, line_num_width);
826        state.advance_both(1);
827    }
828}
829
830#[derive(Clone, Debug)]
831enum Op {
832    Equal(String),
833    Added(String),
834    Removed(String),
835}
836
837fn longest_common_subsequence(seq_a: &[&str], seq_b: &[&str]) -> Vec<(usize, usize)> {
838    let len_a = seq_a.len();
839    let len_b = seq_b.len();
840    let mut dp = vec![vec![0usize; len_b + 1]; len_a + 1];
841    for row in 0..len_a {
842        for col in 0..len_b {
843            if seq_a[row] == seq_b[col] {
844                dp[row + 1][col + 1] = dp[row][col] + 1;
845            } else {
846                dp[row + 1][col + 1] = dp[row + 1][col].max(dp[row][col + 1]);
847            }
848        }
849    }
850    let mut out = Vec::new();
851    let mut row = len_a;
852    let mut col = len_b;
853    while row > 0 && col > 0 {
854        if seq_a[row - 1] == seq_b[col - 1] {
855            out.push((row - 1, col - 1));
856            row -= 1;
857            col -= 1;
858        } else if dp[row - 1][col] >= dp[row][col - 1] {
859            row -= 1;
860        } else {
861            col -= 1;
862        }
863    }
864    out.reverse();
865    out
866}
867
868#[derive(Clone, Debug)]
869enum DiffPart {
870    Equal(Vec<String>),
871    Change {
872        removed: Vec<String>,
873        added: Vec<String>,
874    },
875}
876
877fn collapse_ops(ops: &[Op]) -> Vec<DiffPart> {
878    let mut parts: Vec<DiffPart> = Vec::new();
879    let mut idx = 0usize;
880    while idx < ops.len() {
881        match &ops[idx] {
882            Op::Equal(s) => {
883                let mut equal = vec![s.clone()];
884                idx += 1;
885                while idx < ops.len() {
886                    if let Op::Equal(next) = &ops[idx] {
887                        equal.push(next.clone());
888                        idx += 1;
889                    } else {
890                        break;
891                    }
892                }
893                parts.push(DiffPart::Equal(equal));
894            }
895            Op::Added(_) | Op::Removed(_) => {
896                let mut removed = Vec::new();
897                let mut added = Vec::new();
898                while idx < ops.len() {
899                    match &ops[idx] {
900                        Op::Removed(s) => {
901                            removed.push(s.clone());
902                            idx += 1;
903                        }
904                        Op::Added(s) => {
905                            added.push(s.clone());
906                            idx += 1;
907                        }
908                        Op::Equal(_) => break,
909                    }
910                }
911                parts.push(DiffPart::Change { removed, added });
912            }
913        }
914    }
915    parts
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    #[test]
923    fn strip_bom_detects_prefix() {
924        let (bom, text) = strip_bom("\u{FEFF}hello");
925        assert_eq!(bom, "\u{FEFF}");
926        assert_eq!(text, "hello");
927        let (bom, text) = strip_bom("hello");
928        assert_eq!(bom, "");
929        assert_eq!(text, "hello");
930    }
931
932    #[test]
933    fn detect_line_ending_prefers_first() {
934        assert_eq!(detect_line_ending("a\r\nb\n"), LineEnding::Crlf);
935        assert_eq!(detect_line_ending("a\nb\r\n"), LineEnding::Lf);
936        assert_eq!(detect_line_ending("no newlines"), LineEnding::Lf);
937    }
938
939    #[test]
940    fn normalize_and_restore_crlf() {
941        assert_eq!(normalize_to_lf("a\r\nb\rc"), "a\nb\nc");
942        assert_eq!(
943            restore_line_endings("a\nb\n", LineEnding::Crlf),
944            "a\r\nb\r\n"
945        );
946    }
947
948    #[test]
949    fn fuzzy_normalizes_quotes_dashes_spaces() {
950        let input = "\u{2018}hi\u{2019} \u{2013} \u{00A0}x  ";
951        let out = normalize_for_fuzzy_match(input);
952        assert_eq!(out, "'hi' -  x");
953    }
954
955    #[test]
956    fn exact_single_edit() -> Result<(), String> {
957        let result = apply_edits_to_normalized_content(
958            "Hello, world!",
959            &[Edit {
960                old_text: "world".into(),
961                new_text: "testing".into(),
962            }],
963            "f.txt",
964        )?;
965        assert_eq!(result.new_content, "Hello, testing!");
966        Ok(())
967    }
968
969    #[test]
970    fn empty_old_text_rejected() -> Result<(), String> {
971        let err = apply_edits_to_normalized_content(
972            "abc",
973            &[Edit {
974                old_text: String::new(),
975                new_text: "x".into(),
976            }],
977            "f.txt",
978        )
979        .err()
980        .ok_or_else(|| "empty oldText was accepted".to_owned())?;
981        assert_eq!(err, "oldText must not be empty in f.txt.");
982        Ok(())
983    }
984
985    #[test]
986    fn missing_text_errors() -> Result<(), String> {
987        let err = apply_edits_to_normalized_content(
988            "abc",
989            &[Edit {
990                old_text: "zzz".into(),
991                new_text: "x".into(),
992            }],
993            "f.txt",
994        )
995        .err()
996        .ok_or_else(|| "missing oldText was accepted".to_owned())?;
997        assert!(err.contains("Could not find the exact text"));
998        Ok(())
999    }
1000
1001    #[test]
1002    fn occurrence_count_errors() -> Result<(), String> {
1003        let err = apply_edits_to_normalized_content(
1004            "foo foo foo",
1005            &[Edit {
1006                old_text: "foo".into(),
1007                new_text: "bar".into(),
1008            }],
1009            "f.txt",
1010        )
1011        .err()
1012        .ok_or_else(|| "duplicate oldText was accepted".to_owned())?;
1013        assert!(err.contains("Found 3 occurrences"));
1014        Ok(())
1015    }
1016
1017    #[test]
1018    fn multi_edit_reverse_and_original_coords() -> Result<(), String> {
1019        let result = apply_edits_to_normalized_content(
1020            "foo\nbar\nbaz\n",
1021            &[
1022                Edit {
1023                    old_text: "foo\n".into(),
1024                    new_text: "foo bar\n".into(),
1025                },
1026                Edit {
1027                    old_text: "bar\n".into(),
1028                    new_text: "BAR\n".into(),
1029                },
1030            ],
1031            "f.txt",
1032        )?;
1033        assert_eq!(result.new_content, "foo bar\nBAR\nbaz\n");
1034        Ok(())
1035    }
1036
1037    #[test]
1038    fn overlap_rejected() -> Result<(), String> {
1039        let err = apply_edits_to_normalized_content(
1040            "one\ntwo\nthree\n",
1041            &[
1042                Edit {
1043                    old_text: "one\ntwo\n".into(),
1044                    new_text: "ONE\nTWO\n".into(),
1045                },
1046                Edit {
1047                    old_text: "two\nthree\n".into(),
1048                    new_text: "TWO\nTHREE\n".into(),
1049                },
1050            ],
1051            "f.txt",
1052        )
1053        .err()
1054        .ok_or_else(|| "overlapping edits were accepted".to_owned())?;
1055        assert!(err.contains("overlap"));
1056        Ok(())
1057    }
1058
1059    #[test]
1060    fn no_op_rejected() -> Result<(), String> {
1061        let err = apply_edits_to_normalized_content(
1062            "same",
1063            &[Edit {
1064                old_text: "same".into(),
1065                new_text: "same".into(),
1066            }],
1067            "f.txt",
1068        )
1069        .err()
1070        .ok_or_else(|| "unchanged edit was accepted".to_owned())?;
1071        assert!(err.contains("No changes made"));
1072        Ok(())
1073    }
1074
1075    #[test]
1076    fn fuzzy_preserves_untouched_trailing_whitespace() -> Result<(), String> {
1077        let original = "line one   \nline two  \nline three\n";
1078        let result = apply_edits_to_normalized_content(
1079            original,
1080            &[Edit {
1081                old_text: "line one\nline two\n".into(),
1082                new_text: "replaced\n".into(),
1083            }],
1084            "f.txt",
1085        )?;
1086        assert_eq!(result.new_content, "replaced\nline three\n");
1087        Ok(())
1088    }
1089
1090    #[test]
1091    fn fuzzy_preserves_duplicate_line_bytes() -> Result<(), String> {
1092        let original = ["replace me   ", "after   ", ""].join("\n");
1093        let result = apply_edits_to_normalized_content(
1094            &original,
1095            &[Edit {
1096                old_text: "replace me\n".into(),
1097                new_text: "after\n".into(),
1098            }],
1099            "f.txt",
1100        )?;
1101        assert_eq!(result.new_content, ["after", "after   ", ""].join("\n"));
1102        Ok(())
1103    }
1104
1105    #[test]
1106    fn unified_patch_contains_markers() {
1107        let patch = generate_unified_patch("a.txt", "Hello, world!", "Hello, testing!", 4);
1108        assert!(patch.contains("--- a.txt"));
1109        assert!(patch.contains("+++ a.txt"));
1110        assert!(patch.contains("@@"));
1111        assert!(patch.contains("-Hello, world!"));
1112        assert!(patch.contains("+Hello, testing!"));
1113    }
1114
1115    #[test]
1116    fn unified_patch_splits_distant_changes_with_requested_context() {
1117        let old = (1..=12)
1118            .map(|number| format!("line {number}"))
1119            .collect::<Vec<_>>()
1120            .join("\n");
1121        let mut new_lines = (1..=12)
1122            .map(|number| format!("line {number}"))
1123            .collect::<Vec<_>>();
1124        new_lines[1] = "LINE 2".to_owned();
1125        new_lines[10] = "LINE 11".to_owned();
1126        let patch = generate_unified_patch("a.txt", &old, &new_lines.join("\n"), 1);
1127
1128        assert_eq!(patch.matches("@@ ").count(), 2);
1129        assert!(patch.contains("@@ -1,3 +1,3 @@"));
1130        assert!(patch.contains("@@ -10,3 +10,3 @@"));
1131        assert!(patch.contains(" line 1\n-line 2\n+LINE 2\n line 3\n"));
1132        assert!(!patch.contains(" line 6\n"));
1133    }
1134
1135    #[test]
1136    fn unified_patch_uses_zero_count_headers_for_insert_and_delete() {
1137        let insert = generate_unified_patch("a.txt", "one\ntwo\n", "one\nadded\ntwo\n", 0);
1138        assert!(insert.contains("@@ -1,0 +2,1 @@\n+added\n"));
1139
1140        let delete = generate_unified_patch("a.txt", "one\nremoved\ntwo\n", "one\ntwo\n", 0);
1141        assert!(delete.contains("@@ -2,1 +1,0 @@\n-removed\n"));
1142    }
1143
1144    #[test]
1145    fn display_diff_marks_first_changed_line() {
1146        let result = generate_diff_string("a\nb\nc\n", "a\nB\nc\n", 4);
1147        assert_eq!(result.first_changed_line, Some(2));
1148        assert!(result.diff.contains('B'));
1149    }
1150}