Skip to main content

vtcode_diff/
compute.rs

1//! Structured diff computation and `DiffDocument` construction.
2
3use similar::{ChangeTag, TextDiff};
4
5use super::layout::layout_document;
6use super::parse::{is_unified_metadata_line, parse_hunk_range, parse_omitted_line_count, trim_line_ending};
7use super::types::{
8    DiffBundle, DiffDocument, DiffHunk, DiffLine, DiffLineKind, DiffOptions, DiffRow, DiffStats, LayoutOptions,
9    ParseDiffError, default_inline_timeout,
10};
11
12impl DiffDocument {
13    /// Computes a bounded diff from before/after text.
14    #[must_use]
15    pub fn between(old: &str, new: &str, options: DiffOptions<'_>) -> Self {
16        let old_lines = split_lines_with_terminator(old);
17        let new_lines = split_lines_with_terminator(new);
18        let old_line_refs: Vec<&str> = old_lines.iter().map(String::as_str).collect();
19        let new_line_refs: Vec<&str> = new_lines.iter().map(String::as_str).collect();
20        let mut config = TextDiff::configure();
21        let _ = config.algorithm(options.algorithm.similar()).timeout(options.timeout);
22        let diff = config.diff_slices(&old_line_refs, &new_line_refs);
23        let mut hunks = Vec::new();
24
25        for group in diff.grouped_ops(options.context_lines) {
26            let Some(_first) = group.first() else {
27                continue;
28            };
29            let mut lines = Vec::new();
30            for operation in &group {
31                for change in diff.iter_changes(operation) {
32                    let (kind, old_line, new_line) = match change.tag() {
33                        ChangeTag::Equal => (
34                            DiffLineKind::Context,
35                            change.old_index().and_then(one_based_u32),
36                            change.new_index().and_then(one_based_u32),
37                        ),
38                        ChangeTag::Delete => (DiffLineKind::Deletion, change.old_index().and_then(one_based_u32), None),
39                        ChangeTag::Insert => (DiffLineKind::Addition, None, change.new_index().and_then(one_based_u32)),
40                    };
41                    lines.push(DiffLine {
42                        kind,
43                        old_line,
44                        new_line,
45                        text: change.value().to_owned(),
46                    });
47                }
48            }
49            let old_lines = lines.iter().filter(|line| line.kind != DiffLineKind::Addition).count();
50            let new_lines = lines.iter().filter(|line| line.kind != DiffLineKind::Deletion).count();
51            let old_start = hunk_start_from_lines(&lines, true);
52            let new_start = hunk_start_from_lines(&lines, false);
53            hunks.push(DiffHunk { old_start, old_lines, new_start, new_lines, lines });
54        }
55        let mut document = Self::from_hunks(hunks);
56        document.inline_timeout = options.inline_timeout;
57        document
58    }
59
60    /// Constructs a document from precomputed hunks.
61    #[must_use]
62    pub fn from_hunks(hunks: Vec<DiffHunk>) -> Self {
63        let mut stats = DiffStats { hunks: hunks.len(), ..DiffStats::default() };
64        for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
65            match line.kind {
66                DiffLineKind::Context => stats.context += 1,
67                DiffLineKind::Addition => stats.additions += 1,
68                DiffLineKind::Deletion => stats.deletions += 1,
69            }
70        }
71        Self {
72            hunks,
73            stats,
74            inline_timeout: default_inline_timeout(),
75        }
76    }
77
78    /// Parses unified-diff text into a structured document.
79    ///
80    /// File metadata is accepted and ignored. A body line before the first
81    /// valid hunk header is rejected instead of receiving invented numbers.
82    pub fn from_unified(input: &str) -> Result<Self, ParseDiffError> {
83        let mut hunks = Vec::new();
84        let mut current: Option<DiffHunk> = None;
85        let mut old_line = 0u32;
86        let mut new_line = 0u32;
87        let mut expected_old_lines = 0usize;
88        let mut expected_new_lines = 0usize;
89        let mut omitted_in_current_hunk = false;
90        let mut omitted_rows = 0usize;
91        let mut omitted_tail = Vec::new();
92
93        for raw_with_ending in split_line_slices(input) {
94            let raw = trim_line_ending(raw_with_ending);
95            if raw.starts_with("@@") {
96                if let Some(mut hunk) = current.take() {
97                    assign_omitted_hunk_tail_line_numbers(
98                        &mut hunk,
99                        &omitted_tail,
100                        expected_old_lines,
101                        expected_new_lines,
102                    );
103                    if !omitted_in_current_hunk {
104                        validate_hunk_counts(&hunk, expected_old_lines, expected_new_lines)?;
105                    }
106                    hunks.push(hunk);
107                }
108                omitted_tail.clear();
109                let (old_start, old_count, new_start, new_count) =
110                    parse_hunk_range(raw).ok_or_else(|| ParseDiffError::new("invalid hunk header"))?;
111                old_line = old_start;
112                new_line = new_start;
113                expected_old_lines = old_count;
114                expected_new_lines = new_count;
115                omitted_in_current_hunk = false;
116                current = Some(DiffHunk {
117                    old_start: old_start as usize,
118                    old_lines: 0,
119                    new_start: new_start as usize,
120                    new_lines: 0,
121                    lines: Vec::new(),
122                });
123                continue;
124            }
125            if raw.starts_with("diff ")
126                || raw.starts_with("index ")
127                || raw == r"\ No newline at end of file"
128                || raw.is_empty()
129            {
130                continue;
131            }
132            // Git's file/mode headers precede the first hunk in ordinary
133            // unified output. Keep them out of the body parser while still
134            // rejecting an actual `-...`/`+...` body before any hunk.
135            let hunk_complete = current
136                .as_ref()
137                .is_some_and(|hunk| hunk.old_lines >= expected_old_lines && hunk.new_lines >= expected_new_lines);
138            if (current.is_none() || hunk_complete) && is_unified_metadata_line(raw) {
139                continue;
140            }
141            if hunk_complete && (raw.starts_with("--- ") || raw.starts_with("+++ ")) {
142                continue;
143            }
144            let Some(hunk) = current.as_mut() else {
145                return Err(ParseDiffError::new("diff body appears before a hunk header"));
146            };
147            let is_body_line = matches!(raw.as_bytes().first().copied(), Some(b'-' | b'+' | b' '));
148            if !is_body_line && let Some(omitted) = parse_omitted_line_count(raw) {
149                omitted_in_current_hunk = true;
150                let marker_rows = omitted;
151                let advance = marker_rows
152                    .min(expected_old_lines.saturating_sub(hunk.old_lines))
153                    .min(expected_new_lines.saturating_sub(hunk.new_lines));
154                old_line = old_line.saturating_add(u32::try_from(advance).unwrap_or(u32::MAX));
155                new_line = new_line.saturating_add(u32::try_from(advance).unwrap_or(u32::MAX));
156                hunk.old_lines = hunk.old_lines.saturating_add(advance);
157                hunk.new_lines = hunk.new_lines.saturating_add(advance);
158                omitted_rows = omitted_rows.saturating_add(marker_rows);
159                continue;
160            }
161            let (kind, old_number, new_number) = match raw.as_bytes().first().copied() {
162                Some(b'-') => {
163                    if hunk.old_lines >= expected_old_lines {
164                        return Err(ParseDiffError::new("too many old-side lines for hunk header"));
165                    }
166                    let number = old_line;
167                    old_line = old_line.saturating_add(1);
168                    hunk.old_lines += 1;
169                    (DiffLineKind::Deletion, Some(number), None)
170                }
171                Some(b'+') => {
172                    if hunk.new_lines >= expected_new_lines {
173                        return Err(ParseDiffError::new("too many new-side lines for hunk header"));
174                    }
175                    let number = new_line;
176                    new_line = new_line.saturating_add(1);
177                    hunk.new_lines += 1;
178                    (DiffLineKind::Addition, None, Some(number))
179                }
180                Some(b' ') => {
181                    if hunk.old_lines >= expected_old_lines || hunk.new_lines >= expected_new_lines {
182                        return Err(ParseDiffError::new("too many context lines for hunk header"));
183                    }
184                    let old_number = old_line;
185                    let new_number = new_line;
186                    old_line = old_line.saturating_add(1);
187                    new_line = new_line.saturating_add(1);
188                    hunk.old_lines += 1;
189                    hunk.new_lines += 1;
190                    (DiffLineKind::Context, Some(old_number), Some(new_number))
191                }
192                _ => return Err(ParseDiffError::new("invalid unified diff body line")),
193            };
194            hunk.lines.push(DiffLine {
195                kind,
196                old_line: old_number,
197                new_line: new_number,
198                text: raw_with_ending[1..].to_owned(),
199            });
200            if omitted_in_current_hunk {
201                omitted_tail.push(hunk.lines.len() - 1);
202            }
203        }
204        if let Some(mut hunk) = current {
205            assign_omitted_hunk_tail_line_numbers(&mut hunk, &omitted_tail, expected_old_lines, expected_new_lines);
206            if !omitted_in_current_hunk {
207                validate_hunk_counts(&hunk, expected_old_lines, expected_new_lines)?;
208            }
209            hunks.push(hunk);
210        }
211        let mut document = Self::from_hunks(hunks);
212        document.stats.omitted_rows = omitted_rows;
213        Ok(document)
214    }
215
216    /// Lays out this document with terminal display-width wrapping.
217    #[must_use]
218    pub fn layout(&self, options: LayoutOptions) -> Vec<DiffRow> {
219        layout_document(self, options)
220    }
221}
222fn one_based_u32(index: usize) -> Option<u32> {
223    // Ensures: `None` on out-of-`u32` input, else `Some(index + 1)` without
224    // overflow (checked_add). Total and pure.
225    u32::try_from(index).ok()?.checked_add(1)
226}
227
228fn hunk_start_from_lines(lines: &[DiffLine], old_side: bool) -> usize {
229    // Requires: none. Ensures: returns >= 1 (one-based hunk start fallback).
230    let line_number = lines
231        .iter()
232        .find_map(|line| if old_side { line.old_line } else { line.new_line });
233    if let Some(line_number) = line_number {
234        return usize::try_from(line_number).unwrap_or(usize::MAX).max(1);
235    }
236
237    let counterpart = lines
238        .iter()
239        .find_map(|line| if old_side { line.new_line } else { line.old_line });
240    usize::try_from(counterpart.unwrap_or(1)).unwrap_or(usize::MAX).max(1)
241}
242
243fn validate_hunk_counts(
244    hunk: &DiffHunk,
245    expected_old_lines: usize,
246    expected_new_lines: usize,
247) -> Result<(), ParseDiffError> {
248    // Pearlite-style ensures: `Ok` iff the hunk invariant holds.
249    if hunk.counts_match(expected_old_lines, expected_new_lines) {
250        Ok(())
251    } else {
252        Err(ParseDiffError::new("hunk line counts do not match header"))
253    }
254}
255
256fn assign_omitted_hunk_tail_line_numbers(
257    hunk: &mut DiffHunk,
258    tail: &[usize],
259    expected_old_lines: usize,
260    expected_new_lines: usize,
261) {
262    // Requires: every `tail` entry indexes `hunk.lines`.
263    // Ensures (^hunk): tail lines receive end-anchored numbers; other lines
264    // unchanged. Termination: single pass over `tail` (variant: remaining).
265    if tail.is_empty() {
266        return;
267    }
268    debug_assert!(tail.iter().all(|&index| index < hunk.lines.len()));
269    let old_tail_count = tail
270        .iter()
271        .filter(|&&index| hunk.lines[index].kind != DiffLineKind::Addition)
272        .count();
273    let new_tail_count = tail
274        .iter()
275        .filter(|&&index| hunk.lines[index].kind != DiffLineKind::Deletion)
276        .count();
277    let mut old_line = u32::try_from(hunk.old_start)
278        .unwrap_or(u32::MAX)
279        .saturating_add(u32::try_from(expected_old_lines).unwrap_or(u32::MAX))
280        .saturating_sub(u32::try_from(old_tail_count).unwrap_or(u32::MAX));
281    let mut new_line = u32::try_from(hunk.new_start)
282        .unwrap_or(u32::MAX)
283        .saturating_add(u32::try_from(expected_new_lines).unwrap_or(u32::MAX))
284        .saturating_sub(u32::try_from(new_tail_count).unwrap_or(u32::MAX));
285    for &index in tail {
286        match hunk.lines[index].kind {
287            DiffLineKind::Addition => {
288                hunk.lines[index].new_line = Some(new_line);
289                new_line = new_line.saturating_add(1);
290            }
291            DiffLineKind::Deletion => {
292                hunk.lines[index].old_line = Some(old_line);
293                old_line = old_line.saturating_add(1);
294            }
295            DiffLineKind::Context => {
296                hunk.lines[index].old_line = Some(old_line);
297                hunk.lines[index].new_line = Some(new_line);
298                old_line = old_line.saturating_add(1);
299                new_line = new_line.saturating_add(1);
300            }
301        }
302    }
303}
304/// Computes a structured diff and passes non-empty hunks to a formatter.
305///
306/// # Contract
307///
308/// - Requires: nothing (any `&str` pair accepted).
309/// - Ensures: identical inputs yield an empty bundle without invoking
310///   `formatter`; otherwise the bundle carries the computed hunks.
311pub fn compute_diff<F>(old: &str, new: &str, options: DiffOptions<'_>, formatter: F) -> DiffBundle
312where
313    F: FnOnce(&[DiffHunk], &DiffOptions<'_>) -> String,
314{
315    if old == new {
316        return DiffBundle {
317            hunks: Vec::new(),
318            formatted: String::new(),
319            is_empty: true,
320        };
321    }
322    let document = DiffDocument::between(old, new, options.clone());
323    let formatted = formatter(&document.hunks, &options);
324    DiffBundle { hunks: document.hunks, formatted, is_empty: false }
325}
326pub(crate) fn split_lines_with_terminator(text: &str) -> Vec<String> {
327    split_line_slices(text).into_iter().map(str::to_owned).collect()
328}
329
330/// Splits `text` into line slices that retain their terminators.
331///
332/// # Contract
333///
334/// - Requires: valid UTF-8 `&str` (type-enforced).
335/// - Ensures: concatenation of the result equals `text`; every boundary lies
336///   after `\n`, `\r`, or `\r\n`, hence on a UTF-8 char boundary (ASCII
337///   terminators are single-byte). Empty input yields no lines.
338/// - Termination: variant is `text.len() - index` (strictly decreases).
339pub(crate) fn split_line_slices(text: &str) -> Vec<&str> {
340    let mut lines = Vec::new();
341    let mut start = 0usize;
342    let bytes = text.as_bytes();
343    let mut index = 0usize;
344    // Loop invariant: `start <= index <= text.len()`, `text[start..]` holds
345    // the unsplit suffix, and all pushed slices are char-boundary ranges.
346    while index < bytes.len() {
347        if bytes[index] != b'\n' && bytes[index] != b'\r' {
348            index += 1;
349            continue;
350        }
351        let end = if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
352            index + 2
353        } else {
354            index + 1
355        };
356        debug_assert!(text.is_char_boundary(start) && text.is_char_boundary(end));
357        lines.push(&text[start..end]);
358        start = end;
359        index = end;
360    }
361    if start < text.len() {
362        debug_assert!(text.is_char_boundary(start));
363        lines.push(&text[start..]);
364    }
365    lines
366}