Skip to main content

vtcode_tui/utils/
diff.rs

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