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