Skip to main content

vtcode_diff/
intraline.rs

1//! Bounded character- and word-level intraline refinement.
2
3use std::time::{Duration, Instant};
4
5use similar::{ChangeTag, TextDiff};
6
7use super::types::{Chunk, DiffDisplayKind, DiffDisplayLine, WordChangedRanges, default_inline_timeout};
8
9/// Computes a character-level diff with adjacent chunks coalesced.
10///
11/// # Contract
12///
13/// - Requires: `old` and `new` are valid UTF-8 `&str` (enforced by the type).
14/// - Ensures: every returned [`Chunk`] borrows a char-boundary slice of its
15///   side, and concatenating Equal+Delete reconstructs `old` while
16///   Equal+Insert reconstructs `new`.
17/// - Termination: iterates `similar` changes once (variant: remaining changes).
18#[must_use]
19pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
20    if old == new {
21        return (!old.is_empty()).then_some(Chunk::Equal(old)).into_iter().collect();
22    }
23    let diff = TextDiff::configure()
24        .algorithm(similar::Algorithm::Myers)
25        .timeout(Duration::from_millis(200))
26        .diff_chars(old, new);
27    let mut chunks = Vec::new();
28    let mut old_offset = 0usize;
29    let mut new_offset = 0usize;
30    let mut run: Option<(ChangeTag, usize, usize)> = None;
31
32    for change in diff.iter_all_changes() {
33        let value = change.value();
34        let byte_len = value.len();
35        let (start, end) = match change.tag() {
36            ChangeTag::Equal | ChangeTag::Delete => (old_offset, old_offset.saturating_add(byte_len)),
37            ChangeTag::Insert => (new_offset, new_offset.saturating_add(byte_len)),
38        };
39        if let Some((tag, run_start, run_end)) = run {
40            if tag == change.tag() && run_end == start {
41                run = Some((tag, run_start, end));
42            } else {
43                push_chunk(&mut chunks, tag, run_start, run_end, old, new);
44                run = Some((change.tag(), start, end));
45            }
46        } else {
47            run = Some((change.tag(), start, end));
48        }
49        match change.tag() {
50            ChangeTag::Equal => {
51                old_offset = old_offset.saturating_add(byte_len);
52                new_offset = new_offset.saturating_add(byte_len);
53            }
54            ChangeTag::Delete => old_offset = old_offset.saturating_add(byte_len),
55            ChangeTag::Insert => new_offset = new_offset.saturating_add(byte_len),
56        }
57    }
58    if let Some((tag, start, end)) = run {
59        push_chunk(&mut chunks, tag, start, end, old, new);
60    }
61    chunks
62}
63
64fn push_chunk<'a>(chunks: &mut Vec<Chunk<'a>>, tag: ChangeTag, start: usize, end: usize, old: &'a str, new: &'a str) {
65    // Invariant (Creusot requires): `start..end` must be a char-boundary
66    // slice of the selected side. Offsets are built by summing `value.len()`
67    // partitions of `old`/`new`, so contiguity holds; debug-assert it so a
68    // violated invariant crashes loud in release (debug-assertions stay on).
69    debug_assert!(start <= end, "chunk range must not be inverted");
70    let chunk = match tag {
71        ChangeTag::Equal => {
72            debug_assert!(old.is_char_boundary(start) && old.is_char_boundary(end));
73            Chunk::Equal(&old[start..end])
74        }
75        ChangeTag::Delete => {
76            debug_assert!(old.is_char_boundary(start) && old.is_char_boundary(end));
77            Chunk::Delete(&old[start..end])
78        }
79        ChangeTag::Insert => {
80            debug_assert!(new.is_char_boundary(start) && new.is_char_boundary(end));
81            Chunk::Insert(&new[start..end])
82        }
83    };
84    chunks.push(chunk);
85}
86/// Adds byte-safe intraline ranges to consecutive deletion/addition groups.
87pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
88    annotate_word_level_diffs_with_timeout(lines, default_inline_timeout());
89}
90
91pub(crate) fn annotate_word_level_diffs_with_timeout(lines: &mut [DiffDisplayLine], timeout: Duration) {
92    // Binary content (NUL bytes) makes word-level refinement meaningless and
93    // expensive; skip it entirely so intraline work stays within budget.
94    if lines.iter().any(|line| line.text.as_bytes().contains(&0)) {
95        return;
96    }
97    let deadline = Instant::now().checked_add(timeout);
98    let mut index = 0usize;
99    while index < lines.len() {
100        if lines[index].kind != DiffDisplayKind::Deletion {
101            index += 1;
102            continue;
103        }
104        let delete_start = index;
105        while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
106            index += 1;
107        }
108        let insert_start = index;
109        while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
110            index += 1;
111        }
112        let pair_count = (insert_start - delete_start).min(index - insert_start);
113        for offset in 0..pair_count {
114            if deadline.is_some_and(|limit| Instant::now() >= limit) {
115                return;
116            }
117            let (old_ranges, new_ranges) =
118                word_level_changed_ranges(&lines[delete_start + offset].text, &lines[insert_start + offset].text);
119            lines[delete_start + offset].changed = old_ranges;
120            lines[insert_start + offset].changed = new_ranges;
121        }
122    }
123}
124
125/// Computes byte-safe word-level changed ranges for a line pair.
126///
127/// # Contract
128///
129/// - Requires: nothing beyond valid `&str` inputs.
130/// - Ensures: every returned range is a char-boundary range within its side,
131///   and an empty input or an over-budget pair (`> 16_384` bytes) yields no
132///   ranges. Overflow-free: offsets advance with `saturating_add`.
133/// - Termination: single pass over `similar` changes.
134#[must_use]
135pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
136    if old.is_empty() || new.is_empty() || old.len().saturating_add(new.len()) > 16_384 {
137        return (Vec::new(), Vec::new());
138    }
139    let diff = TextDiff::configure()
140        .algorithm(similar::Algorithm::Myers)
141        .timeout(Duration::from_millis(10))
142        .diff_unicode_words(old, new);
143    if diff.ratio() < 0.35 {
144        return (Vec::new(), Vec::new());
145    }
146    let mut old_offset = 0usize;
147    let mut new_offset = 0usize;
148    let mut old_ranges = Vec::new();
149    let mut new_ranges = Vec::new();
150    for change in diff.iter_all_changes() {
151        let length = change.value().len();
152        match change.tag() {
153            ChangeTag::Equal => {
154                old_offset = old_offset.saturating_add(length);
155                new_offset = new_offset.saturating_add(length);
156            }
157            ChangeTag::Delete => {
158                push_range(&mut old_ranges, old_offset, old_offset.saturating_add(length));
159                old_offset = old_offset.saturating_add(length);
160            }
161            ChangeTag::Insert => {
162                push_range(&mut new_ranges, new_offset, new_offset.saturating_add(length));
163                new_offset = new_offset.saturating_add(length);
164            }
165        }
166    }
167    (old_ranges, new_ranges)
168}
169
170fn push_range(ranges: &mut WordChangedRanges, start: usize, end: usize) {
171    // Invariant: ranges stay ordered and coalesced (`prior_end == start`
172    // extends instead of pushing). Callers pass saturating offsets.
173    debug_assert!(start <= end, "intraline range must not be inverted");
174    match ranges.last_mut() {
175        Some((_, prior_end)) if *prior_end == start => *prior_end = end,
176        _ => ranges.push((start, end)),
177    }
178}