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#[must_use]
11pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
12    if old == new {
13        return (!old.is_empty()).then_some(Chunk::Equal(old)).into_iter().collect();
14    }
15    let diff = TextDiff::configure()
16        .algorithm(similar::Algorithm::Myers)
17        .timeout(Duration::from_millis(200))
18        .diff_chars(old, new);
19    let mut chunks = Vec::new();
20    let mut old_offset = 0usize;
21    let mut new_offset = 0usize;
22    let mut run: Option<(ChangeTag, usize, usize)> = None;
23
24    for change in diff.iter_all_changes() {
25        let value = change.value();
26        let byte_len = value.len();
27        let (start, end) = match change.tag() {
28            ChangeTag::Equal | ChangeTag::Delete => (old_offset, old_offset.saturating_add(byte_len)),
29            ChangeTag::Insert => (new_offset, new_offset.saturating_add(byte_len)),
30        };
31        if let Some((tag, run_start, run_end)) = run {
32            if tag == change.tag() && run_end == start {
33                run = Some((tag, run_start, end));
34            } else {
35                push_chunk(&mut chunks, tag, run_start, run_end, old, new);
36                run = Some((change.tag(), start, end));
37            }
38        } else {
39            run = Some((change.tag(), start, end));
40        }
41        match change.tag() {
42            ChangeTag::Equal => {
43                old_offset = old_offset.saturating_add(byte_len);
44                new_offset = new_offset.saturating_add(byte_len);
45            }
46            ChangeTag::Delete => old_offset = old_offset.saturating_add(byte_len),
47            ChangeTag::Insert => new_offset = new_offset.saturating_add(byte_len),
48        }
49    }
50    if let Some((tag, start, end)) = run {
51        push_chunk(&mut chunks, tag, start, end, old, new);
52    }
53    chunks
54}
55
56fn push_chunk<'a>(chunks: &mut Vec<Chunk<'a>>, tag: ChangeTag, start: usize, end: usize, old: &'a str, new: &'a str) {
57    let chunk = match tag {
58        ChangeTag::Equal => Chunk::Equal(&old[start..end]),
59        ChangeTag::Delete => Chunk::Delete(&old[start..end]),
60        ChangeTag::Insert => Chunk::Insert(&new[start..end]),
61    };
62    chunks.push(chunk);
63}
64/// Adds byte-safe intraline ranges to consecutive deletion/addition groups.
65pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
66    annotate_word_level_diffs_with_timeout(lines, default_inline_timeout());
67}
68
69pub(crate) fn annotate_word_level_diffs_with_timeout(lines: &mut [DiffDisplayLine], timeout: Duration) {
70    // Binary content (NUL bytes) makes word-level refinement meaningless and
71    // expensive; skip it entirely so intraline work stays within budget.
72    if lines.iter().any(|line| line.text.as_bytes().contains(&0)) {
73        return;
74    }
75    let deadline = Instant::now().checked_add(timeout);
76    let mut index = 0usize;
77    while index < lines.len() {
78        if lines[index].kind != DiffDisplayKind::Deletion {
79            index += 1;
80            continue;
81        }
82        let delete_start = index;
83        while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
84            index += 1;
85        }
86        let insert_start = index;
87        while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
88            index += 1;
89        }
90        let pair_count = (insert_start - delete_start).min(index - insert_start);
91        for offset in 0..pair_count {
92            if deadline.is_some_and(|limit| Instant::now() >= limit) {
93                return;
94            }
95            let (old_ranges, new_ranges) =
96                word_level_changed_ranges(&lines[delete_start + offset].text, &lines[insert_start + offset].text);
97            lines[delete_start + offset].changed = old_ranges;
98            lines[insert_start + offset].changed = new_ranges;
99        }
100    }
101}
102
103/// Computes byte-safe word-level changed ranges for a line pair.
104#[must_use]
105pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
106    if old.is_empty() || new.is_empty() || old.len().saturating_add(new.len()) > 16_384 {
107        return (Vec::new(), Vec::new());
108    }
109    let diff = TextDiff::configure()
110        .algorithm(similar::Algorithm::Myers)
111        .timeout(Duration::from_millis(10))
112        .diff_unicode_words(old, new);
113    if diff.ratio() < 0.35 {
114        return (Vec::new(), Vec::new());
115    }
116    let mut old_offset = 0usize;
117    let mut new_offset = 0usize;
118    let mut old_ranges = Vec::new();
119    let mut new_ranges = Vec::new();
120    for change in diff.iter_all_changes() {
121        let length = change.value().len();
122        match change.tag() {
123            ChangeTag::Equal => {
124                old_offset += length;
125                new_offset += length;
126            }
127            ChangeTag::Delete => {
128                push_range(&mut old_ranges, old_offset, old_offset + length);
129                old_offset += length;
130            }
131            ChangeTag::Insert => {
132                push_range(&mut new_ranges, new_offset, new_offset + length);
133                new_offset += length;
134            }
135        }
136    }
137    (old_ranges, new_ranges)
138}
139
140fn push_range(ranges: &mut WordChangedRanges, start: usize, end: usize) {
141    match ranges.last_mut() {
142        Some((_, prior_end)) if *prior_end == start => *prior_end = end,
143        _ => ranges.push((start, end)),
144    }
145}