Skip to main content

vtcode_tui/utils/
diff.rs

1//! Diff utilities for generating structured diffs.
2
3use anstyle::Reset;
4use serde::Serialize;
5use std::cmp::min;
6use std::collections::HashMap;
7use std::fmt::Write;
8
9use crate::ui::theme;
10
11/// Represents a chunk of text in a diff (Equal, Delete, or Insert).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Chunk<'a> {
14    Equal(&'a str),
15    Delete(&'a str),
16    Insert(&'a str),
17}
18
19/// Compute an optimal diff between two strings using Myers algorithm.
20pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
21    if old.is_empty() && new.is_empty() {
22        return Vec::with_capacity(0);
23    }
24    if old.is_empty() {
25        return vec![Chunk::Insert(new)];
26    }
27    if new.is_empty() {
28        return vec![Chunk::Delete(old)];
29    }
30
31    // Strip common prefix and suffix first (optimization)
32    let mut prefix_len = 0;
33    for (o, n) in old.chars().zip(new.chars()) {
34        if o == n {
35            prefix_len += o.len_utf8();
36        } else {
37            break;
38        }
39    }
40
41    let mut suffix_len = 0;
42    let old_rest = &old[prefix_len..];
43    let new_rest = &new[prefix_len..];
44
45    let old_chars: Vec<char> = old_rest.chars().collect();
46    let new_chars: Vec<char> = new_rest.chars().collect();
47
48    for (o, n) in old_chars.iter().rev().zip(new_chars.iter().rev()) {
49        if o == n {
50            suffix_len += o.len_utf8();
51        } else {
52            break;
53        }
54    }
55
56    let old_middle_end = old_rest.len() - suffix_len;
57    let new_middle_end = new_rest.len() - suffix_len;
58
59    let old_middle = &old_rest[..old_middle_end];
60    let new_middle = &new_rest[..new_middle_end];
61
62    let mut result = Vec::with_capacity(old_middle.len() + new_middle.len());
63
64    // Add common prefix
65    if prefix_len > 0 {
66        result.push(Chunk::Equal(&old[..prefix_len]));
67    }
68
69    // Compute optimal diff for the middle section
70    if !old_middle.is_empty() || !new_middle.is_empty() {
71        let old_chars: Vec<char> = old_middle.chars().collect();
72        let new_chars: Vec<char> = new_middle.chars().collect();
73        let edits = myers_diff(&old_chars, &new_chars);
74
75        let mut old_pos = 0;
76        let mut new_pos = 0;
77
78        for edit in edits {
79            match edit {
80                Edit::Equal => {
81                    old_pos += 1;
82                    new_pos += 1;
83                }
84                Edit::Delete => {
85                    let ch = old_chars[old_pos];
86                    let byte_start = old_middle.char_indices().nth(old_pos).unwrap().0;
87                    let byte_end = byte_start + ch.len_utf8();
88                    result.push(Chunk::Delete(&old_middle[byte_start..byte_end]));
89                    old_pos += 1;
90                }
91                Edit::Insert => {
92                    let ch = new_chars[new_pos];
93                    let byte_start = new_middle.char_indices().nth(new_pos).unwrap().0;
94                    let byte_end = byte_start + ch.len_utf8();
95                    result.push(Chunk::Insert(&new_middle[byte_start..byte_end]));
96                    new_pos += 1;
97                }
98            }
99        }
100    }
101
102    // Add common suffix
103    if suffix_len > 0 {
104        result.push(Chunk::Equal(&old[old.len() - suffix_len..]));
105    }
106
107    result
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum Edit {
112    Equal,
113    Delete,
114    Insert,
115}
116
117fn myers_diff(old: &[char], new: &[char]) -> Vec<Edit> {
118    let n = old.len();
119    let m = new.len();
120
121    if n == 0 {
122        return vec![Edit::Insert; m];
123    }
124    if m == 0 {
125        return vec![Edit::Delete; n];
126    }
127
128    let max_d = n + m;
129    let mut v = vec![0; 2 * max_d + 1];
130    let mut v_index = vec![0usize; (max_d + 1) * (2 * max_d + 1)];
131    let row_len = 2 * max_d + 1;
132
133    v[max_d] = 0;
134
135    for d in 0..=max_d {
136        for k in (-(d as i32)..=(d as i32)).step_by(2) {
137            let k_idx = (k + max_d as i32) as usize;
138
139            let x = if k == -(d as i32) || (k != d as i32 && v[k_idx - 1] < v[k_idx + 1]) {
140                v[k_idx + 1]
141            } else {
142                v[k_idx - 1] + 1
143            };
144
145            let mut x = x;
146            let mut y = (x as i32 - k) as usize;
147
148            while x < n && y < m && old[x] == new[y] {
149                x += 1;
150                y += 1;
151            }
152
153            v[k_idx] = x;
154            v_index[d * row_len + k_idx] = x;
155
156            if x >= n && y >= m {
157                return backtrack_myers(old, new, &v_index, d, k, max_d);
158            }
159        }
160    }
161
162    vec![]
163}
164
165fn backtrack_myers(
166    old: &[char],
167    new: &[char],
168    v_index: &[usize],
169    d: usize,
170    mut k: i32,
171    max_d: usize,
172) -> Vec<Edit> {
173    let mut edits = Vec::with_capacity(old.len() + new.len());
174    let mut x = old.len();
175    let mut y = new.len();
176    let row_len = 2 * max_d + 1;
177
178    for cur_d in (0..=d).rev() {
179        if cur_d == 0 {
180            while x > 0 && y > 0 {
181                edits.push(Edit::Equal);
182                x -= 1;
183                y -= 1;
184            }
185            break;
186        }
187
188        let k_idx = (k + max_d as i32) as usize;
189
190        let prev_k = if k == -(cur_d as i32)
191            || (k != cur_d as i32
192                && v_index[(cur_d - 1) * row_len + k_idx - 1]
193                    < v_index[(cur_d - 1) * row_len + k_idx + 1])
194        {
195            k + 1
196        } else {
197            k - 1
198        };
199
200        let prev_k_idx = (prev_k + max_d as i32) as usize;
201        let prev_x_val = v_index[(cur_d - 1) * row_len + prev_k_idx];
202        let prev_y = (prev_x_val as i32 - prev_k) as usize;
203
204        let (move_x, move_y) = if prev_k == k + 1 {
205            (prev_x_val, prev_y + 1)
206        } else {
207            (prev_x_val + 1, prev_y)
208        };
209
210        while x > move_x && y > move_y {
211            edits.push(Edit::Equal);
212            x -= 1;
213            y -= 1;
214        }
215
216        if prev_k == k + 1 {
217            edits.push(Edit::Insert);
218            y -= 1;
219        } else {
220            edits.push(Edit::Delete);
221            x -= 1;
222        }
223
224        k = prev_k;
225    }
226
227    edits.reverse();
228    edits
229}
230
231/// Options for diff generation.
232#[derive(Debug, Clone)]
233pub struct DiffOptions<'a> {
234    pub context_lines: usize,
235    pub old_label: Option<&'a str>,
236    pub new_label: Option<&'a str>,
237    pub missing_newline_hint: bool,
238}
239
240impl Default for DiffOptions<'_> {
241    fn default() -> Self {
242        Self {
243            context_lines: 3,
244            old_label: None,
245            new_label: None,
246            missing_newline_hint: true,
247        }
248    }
249}
250
251/// A diff rendered with both structured hunks and formatted text.
252#[derive(Debug, Clone, Serialize)]
253pub struct DiffBundle {
254    pub hunks: Vec<DiffHunk>,
255    pub formatted: String,
256    pub is_empty: bool,
257}
258
259/// A diff hunk with metadata for old/new ranges.
260#[derive(Debug, Clone, Serialize)]
261pub struct DiffHunk {
262    pub old_start: usize,
263    pub old_lines: usize,
264    pub new_start: usize,
265    pub new_lines: usize,
266    pub lines: Vec<DiffLine>,
267}
268
269/// A single diff line annotated with metadata and type.
270#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
271#[serde(rename_all = "snake_case")]
272pub enum DiffLineKind {
273    Context,
274    Addition,
275    Deletion,
276}
277
278/// Metadata for a single line inside a diff hunk.
279#[derive(Debug, Clone, Serialize)]
280pub struct DiffLine {
281    pub kind: DiffLineKind,
282    pub old_line: Option<usize>,
283    pub new_line: Option<usize>,
284    pub text: String,
285}
286
287/// Compute a structured diff bundle.
288pub fn compute_diff<F>(old: &str, new: &str, options: DiffOptions<'_>, formatter: F) -> DiffBundle
289where
290    F: FnOnce(&[DiffHunk], &DiffOptions<'_>) -> String,
291{
292    let old_lines_owned = split_lines_with_terminator(old);
293    let new_lines_owned = split_lines_with_terminator(new);
294
295    let old_refs: Vec<&str> = old_lines_owned.iter().map(|s| s.as_str()).collect();
296    let new_refs: Vec<&str> = new_lines_owned.iter().map(|s| s.as_str()).collect();
297
298    let records = collect_line_records(&old_refs, &new_refs);
299    let has_changes = records
300        .iter()
301        .any(|record| matches!(record.kind, DiffLineKind::Addition | DiffLineKind::Deletion));
302
303    let hunks = if has_changes {
304        build_hunks(&records, options.context_lines)
305    } else {
306        Vec::new()
307    };
308
309    let formatted = if hunks.is_empty() {
310        String::new()
311    } else {
312        formatter(&hunks, &options)
313    };
314
315    DiffBundle {
316        hunks,
317        formatted,
318        is_empty: !has_changes,
319    }
320}
321
322/// Format a unified diff without ANSI color codes.
323pub fn format_unified_diff(old: &str, new: &str, options: DiffOptions<'_>) -> String {
324    let mut options = options;
325    options.missing_newline_hint = false;
326    let bundle = compute_diff(old, new, options, format_colored_diff);
327    crate::utils::ansi_parser::strip_ansi(&bundle.formatted)
328}
329
330/// Compute a structured diff bundle using the default theme-aware formatter.
331pub fn compute_diff_with_theme(old: &str, new: &str, options: DiffOptions<'_>) -> DiffBundle {
332    compute_diff(old, new, options, format_colored_diff)
333}
334
335/// Format diff hunks with theme colors for terminal display.
336pub fn format_colored_diff(hunks: &[DiffHunk], options: &DiffOptions<'_>) -> String {
337    if hunks.is_empty() {
338        return String::new();
339    }
340
341    let active_styles = theme::active_styles();
342    let header_style = active_styles.status;
343    let hunk_header_style = active_styles.status;
344    let addition_style = active_styles.secondary;
345    let deletion_style = active_styles.error;
346    let context_style = active_styles.output;
347
348    let mut output = String::new();
349
350    if let (Some(old_label), Some(new_label)) = (options.old_label, options.new_label) {
351        let _ = write!(
352            output,
353            "{}--- {old_label}\n{}",
354            header_style.render(),
355            Reset.render()
356        );
357
358        let _ = write!(
359            output,
360            "{}+++ {new_label}\n{}",
361            header_style.render(),
362            Reset.render()
363        );
364    }
365
366    for hunk in hunks {
367        let _ = write!(
368            output,
369            "{}@@ -{},{} +{},{} @@\n{}",
370            hunk_header_style.render(),
371            hunk.old_start,
372            hunk.old_lines,
373            hunk.new_start,
374            hunk.new_lines,
375            Reset.render()
376        );
377
378        for line in &hunk.lines {
379            let (style, prefix) = match line.kind {
380                DiffLineKind::Addition => (&addition_style, '+'),
381                DiffLineKind::Deletion => (&deletion_style, '-'),
382                DiffLineKind::Context => (&context_style, ' '),
383            };
384
385            let mut display = String::with_capacity(line.text.len() + 2);
386            display.push(prefix);
387            display.push_str(&line.text);
388
389            let has_newline = display.ends_with('\n');
390            let display_content = if has_newline {
391                &display[..display.len() - 1]
392            } else {
393                &display
394            };
395
396            let _ = write!(
397                output,
398                "{}{} {}",
399                style.render(),
400                display_content,
401                Reset.render()
402            );
403            output.push('\n');
404
405            if options.missing_newline_hint && !line.text.ends_with('\n') {
406                let eof_hint = r"\ No newline at end of file";
407                let _ = write!(
408                    output,
409                    "{}{} {}",
410                    context_style.render(),
411                    eof_hint,
412                    Reset.render()
413                );
414                output.push('\n');
415            }
416        }
417    }
418
419    output
420}
421
422fn split_lines_with_terminator(text: &str) -> Vec<String> {
423    if text.is_empty() {
424        return Vec::with_capacity(0);
425    }
426
427    let mut lines: Vec<String> = text
428        .split_inclusive('\n')
429        .map(|line| line.to_string())
430        .collect();
431
432    if lines.is_empty() {
433        lines.push(text.to_string());
434    }
435
436    lines
437}
438
439fn collect_line_records<'a>(
440    old_lines: &'a [&'a str],
441    new_lines: &'a [&'a str],
442) -> Vec<LineRecord<'a>> {
443    let (old_encoded, new_encoded) = encode_line_sequences(old_lines, new_lines);
444    let mut records = Vec::with_capacity(old_lines.len() + new_lines.len());
445    let mut old_index = 0usize;
446    let mut new_index = 0usize;
447
448    for chunk in compute_diff_chunks(old_encoded.as_str(), new_encoded.as_str()) {
449        match chunk {
450            Chunk::Equal(text) => {
451                for _ in text.chars() {
452                    let old_line = old_index + 1;
453                    let new_line = new_index + 1;
454                    let line = old_lines[old_index];
455                    records.push(LineRecord {
456                        kind: DiffLineKind::Context,
457                        old_line: Some(old_line),
458                        new_line: Some(new_line),
459                        text: line,
460                        anchor_old: old_line,
461                        anchor_new: new_line,
462                    });
463                    old_index += 1;
464                    new_index += 1;
465                }
466            }
467            Chunk::Delete(text) => {
468                for _ in text.chars() {
469                    let old_line = old_index + 1;
470                    let anchor_new = new_index + 1;
471                    let line = old_lines[old_index];
472                    records.push(LineRecord {
473                        kind: DiffLineKind::Deletion,
474                        old_line: Some(old_line),
475                        new_line: None,
476                        text: line,
477                        anchor_old: old_line,
478                        anchor_new,
479                    });
480                    old_index += 1;
481                }
482            }
483            Chunk::Insert(text) => {
484                for _ in text.chars() {
485                    let new_line = new_index + 1;
486                    let anchor_old = old_index + 1;
487                    let line = new_lines[new_index];
488                    records.push(LineRecord {
489                        kind: DiffLineKind::Addition,
490                        old_line: None,
491                        new_line: Some(new_line),
492                        text: line,
493                        anchor_old,
494                        anchor_new: new_line,
495                    });
496                    new_index += 1;
497                }
498            }
499        }
500    }
501
502    records
503}
504
505fn encode_line_sequences<'a>(
506    old_lines: &'a [&'a str],
507    new_lines: &'a [&'a str],
508) -> (String, String) {
509    let mut token_map: HashMap<&'a str, char> = HashMap::new();
510    let mut next_codepoint: u32 = 0;
511
512    let old_encoded = encode_line_list(old_lines, &mut token_map, &mut next_codepoint);
513    let new_encoded = encode_line_list(new_lines, &mut token_map, &mut next_codepoint);
514
515    (old_encoded, new_encoded)
516}
517
518fn encode_line_list<'a>(
519    lines: &'a [&'a str],
520    map: &mut HashMap<&'a str, char>,
521    next_codepoint: &mut u32,
522) -> String {
523    let mut encoded = String::with_capacity(lines.len());
524    for &line in lines {
525        let token = if let Some(&value) = map.get(line) {
526            value
527        } else {
528            let ch = next_token_char(next_codepoint).expect("exceeded diff token capacity");
529            map.insert(line, ch);
530            ch
531        };
532        encoded.push(token);
533    }
534    encoded
535}
536
537fn next_token_char(counter: &mut u32) -> Option<char> {
538    while *counter <= 0x10FFFF {
539        let candidate = *counter;
540        *counter += 1;
541        if (0xD800..=0xDFFF).contains(&candidate) {
542            continue;
543        }
544        if let Some(ch) = char::from_u32(candidate) {
545            return Some(ch);
546        }
547    }
548    None
549}
550
551#[derive(Debug)]
552struct LineRecord<'a> {
553    kind: DiffLineKind,
554    old_line: Option<usize>,
555    new_line: Option<usize>,
556    text: &'a str,
557    anchor_old: usize,
558    anchor_new: usize,
559}
560
561fn build_hunks(records: &[LineRecord<'_>], context: usize) -> Vec<DiffHunk> {
562    if records.is_empty() {
563        return Vec::new();
564    }
565
566    let ranges = compute_hunk_ranges(records, context);
567    let mut hunks = Vec::with_capacity(ranges.len());
568
569    for (start, end) in ranges {
570        let slice = &records[start..=end];
571
572        let old_start = slice
573            .iter()
574            .filter_map(|r| r.old_line)
575            .min()
576            .or_else(|| slice.iter().map(|r| r.anchor_old).min())
577            .unwrap_or(1);
578
579        let new_start = slice
580            .iter()
581            .filter_map(|r| r.new_line)
582            .min()
583            .or_else(|| slice.iter().map(|r| r.anchor_new).min())
584            .unwrap_or(1);
585
586        let old_lines = slice
587            .iter()
588            .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Deletion))
589            .count();
590        let new_lines = slice
591            .iter()
592            .filter(|r| matches!(r.kind, DiffLineKind::Context | DiffLineKind::Addition))
593            .count();
594
595        let lines = slice
596            .iter()
597            .map(|record| DiffLine {
598                kind: record.kind.clone(),
599                old_line: record.old_line,
600                new_line: record.new_line,
601                text: record.text.to_string(),
602            })
603            .collect();
604
605        hunks.push(DiffHunk {
606            old_start,
607            old_lines,
608            new_start,
609            new_lines,
610            lines,
611        });
612    }
613
614    hunks
615}
616
617fn compute_hunk_ranges(records: &[LineRecord<'_>], context: usize) -> Vec<(usize, usize)> {
618    let mut ranges = Vec::with_capacity(4);
619    let mut current_start: Option<usize> = None;
620    let mut current_end: usize = 0;
621
622    for (idx, record) in records.iter().enumerate() {
623        if record.kind != DiffLineKind::Context {
624            let start = idx.saturating_sub(context);
625            let end = min(idx + context, records.len().saturating_sub(1));
626
627            if let Some(existing_start) = current_start {
628                if start < existing_start {
629                    current_start = Some(start);
630                }
631                if end > current_end {
632                    current_end = end;
633                }
634            } else {
635                current_start = Some(start);
636                current_end = end;
637            }
638        } else if let Some(start) = current_start
639            && idx > current_end
640        {
641            ranges.push((start, current_end));
642            current_start = None;
643        }
644    }
645
646    if let Some(start) = current_start {
647        ranges.push((start, current_end));
648    }
649
650    ranges
651}