Skip to main content

tui/diffs/
diff_types.rs

1use similar::{DiffOp, TextDiff};
2
3/// Tag indicating the kind of change a diff line represents.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum DiffTag {
6    Context,
7    Removed,
8    Added,
9}
10
11/// A single line in a diff, tagged with its change type.
12#[derive(Debug, Clone, PartialEq)]
13pub struct DiffLine {
14    pub tag: DiffTag,
15    pub content: String,
16}
17
18/// A row in a side-by-side diff, pairing an old (left) line with a new (right) line.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SplitDiffRow {
21    pub left: Option<SplitDiffEntry>,
22    pub right: Option<SplitDiffEntry>,
23}
24
25/// One side of a split diff row.
26#[derive(Debug, Clone, PartialEq)]
27pub struct SplitDiffEntry {
28    pub tag: DiffTag,
29    pub content: String,
30    pub line_number: Option<usize>,
31}
32
33impl SplitDiffEntry {
34    pub fn new(tag: DiffTag, content: impl Into<String>, line_number: Option<usize>) -> Self {
35        Self { tag, content: content.into(), line_number }
36    }
37}
38
39/// A preview of changed lines for an edit operation.
40#[derive(Debug, Clone, PartialEq)]
41pub struct DiffPreview {
42    /// Flat list of diff lines — used by the unified renderer.
43    pub lines: Vec<DiffLine>,
44    /// Paired rows — used by the split (side-by-side) renderer.
45    pub rows: Vec<SplitDiffRow>,
46    pub lang_hint: String,
47    /// 1-indexed line number where the edit begins in the original file.
48    pub start_line: Option<usize>,
49}
50
51impl DiffPreview {
52    pub fn compute(old: &str, new: &str, lang_hint: &str) -> Self {
53        build_diff(old, new, lang_hint, false)
54    }
55
56    pub fn compute_trimmed(old: &str, new: &str, lang_hint: &str) -> Self {
57        build_diff(old, new, lang_hint, true)
58    }
59}
60
61fn build_diff(old: &str, new: &str, lang_hint: &str, trim: bool) -> DiffPreview {
62    let text_diff = TextDiff::from_lines(old, new);
63    let old_lines: Vec<&str> = old.lines().collect();
64    let new_lines: Vec<&str> = new.lines().collect();
65    let mut state = DiffBuildState::default();
66
67    for op in text_diff.ops() {
68        process_diff_op(*op, &old_lines, &new_lines, &mut state);
69    }
70
71    let DiffBuildState { mut lines, mut rows, mut first_change_line, .. } = state;
72    if trim {
73        trim_context(&mut lines, &mut rows, &mut first_change_line);
74    }
75
76    DiffPreview { lines, rows, lang_hint: lang_hint.to_string(), start_line: first_change_line }
77}
78
79#[derive(Default)]
80struct DiffBuildState {
81    lines: Vec<DiffLine>,
82    rows: Vec<SplitDiffRow>,
83    first_change_line: Option<usize>,
84    old_line_num: usize,
85    new_line_num: usize,
86}
87
88fn get_line<'a>(lines: &[&'a str], index: usize) -> &'a str {
89    lines.get(index).unwrap_or(&"").trim_end_matches('\n')
90}
91
92fn process_diff_op(op: DiffOp, old: &[&str], new: &[&str], s: &mut DiffBuildState) {
93    match op {
94        DiffOp::Equal { old_index, len, .. } => {
95            for i in 0..len {
96                s.old_line_num += 1;
97                s.new_line_num += 1;
98                let content = get_line(old, old_index + i).to_string();
99                s.lines.push(DiffLine { tag: DiffTag::Context, content: content.clone() });
100                s.rows.push(SplitDiffRow {
101                    left: Some(SplitDiffEntry::new(DiffTag::Context, content.clone(), Some(s.old_line_num))),
102                    right: Some(SplitDiffEntry::new(DiffTag::Context, content, Some(s.new_line_num))),
103                });
104            }
105        }
106        DiffOp::Delete { old_index, old_len, .. } => {
107            if s.first_change_line.is_none() {
108                s.first_change_line = Some(s.old_line_num + 1);
109            }
110            for i in 0..old_len {
111                s.old_line_num += 1;
112                let content = get_line(old, old_index + i).to_string();
113                s.lines.push(DiffLine { tag: DiffTag::Removed, content: content.clone() });
114                s.rows.push(SplitDiffRow {
115                    left: Some(SplitDiffEntry::new(DiffTag::Removed, content, Some(s.old_line_num))),
116                    right: None,
117                });
118            }
119        }
120        DiffOp::Insert { new_index, new_len, .. } => {
121            if s.first_change_line.is_none() {
122                s.first_change_line = Some(s.old_line_num + 1);
123            }
124            for i in 0..new_len {
125                s.new_line_num += 1;
126                let content = get_line(new, new_index + i).to_string();
127                s.lines.push(DiffLine { tag: DiffTag::Added, content: content.clone() });
128                s.rows.push(SplitDiffRow {
129                    left: None,
130                    right: Some(SplitDiffEntry::new(DiffTag::Added, content, Some(s.new_line_num))),
131                });
132            }
133        }
134        DiffOp::Replace { old_index, old_len, new_index, new_len } => {
135            if s.first_change_line.is_none() {
136                s.first_change_line = Some(s.old_line_num + 1);
137            }
138            for i in 0..old_len {
139                s.lines.push(DiffLine { tag: DiffTag::Removed, content: get_line(old, old_index + i).to_string() });
140            }
141            for i in 0..new_len {
142                s.lines.push(DiffLine { tag: DiffTag::Added, content: get_line(new, new_index + i).to_string() });
143            }
144            for i in 0..old_len.max(new_len) {
145                let left = (i < old_len).then(|| {
146                    s.old_line_num += 1;
147                    SplitDiffEntry::new(DiffTag::Removed, get_line(old, old_index + i), Some(s.old_line_num))
148                });
149                let right = (i < new_len).then(|| {
150                    s.new_line_num += 1;
151                    SplitDiffEntry::new(DiffTag::Added, get_line(new, new_index + i), Some(s.new_line_num))
152                });
153                s.rows.push(SplitDiffRow { left, right });
154            }
155        }
156    }
157}
158
159fn trim_context(lines: &mut Vec<DiffLine>, rows: &mut Vec<SplitDiffRow>, first_change_line: &mut Option<usize>) {
160    const CONTEXT_LINES: usize = 3;
161
162    let first_change_idx = lines.iter().position(|l| l.tag != DiffTag::Context);
163    let last_change_idx = lines.iter().rposition(|l| l.tag != DiffTag::Context);
164
165    if let (Some(first), Some(last)) = (first_change_idx, last_change_idx) {
166        let start = first.saturating_sub(CONTEXT_LINES);
167        let end = (last + CONTEXT_LINES + 1).min(lines.len());
168        lines.drain(..start);
169        lines.truncate(end - start);
170        let trimmed_context = first - start;
171        *first_change_line = first_change_line.map(|l| l - trimmed_context);
172    }
173
174    let first_row = rows.iter().position(|r| !is_context_row(r));
175    let last_row = rows.iter().rposition(|r| !is_context_row(r));
176
177    if let (Some(first), Some(last)) = (first_row, last_row) {
178        let start = first.saturating_sub(CONTEXT_LINES);
179        let end = (last + CONTEXT_LINES + 1).min(rows.len());
180        rows.drain(..start);
181        rows.truncate(end - start);
182    }
183}
184
185fn is_context_row(row: &SplitDiffRow) -> bool {
186    row.left.as_ref().is_none_or(|c| c.tag == DiffTag::Context)
187        && row.right.as_ref().is_none_or(|c| c.tag == DiffTag::Context)
188}