1use similar::{DiffOp, TextDiff};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum DiffTag {
6 Context,
7 Removed,
8 Added,
9}
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct DiffLine {
14 pub tag: DiffTag,
15 pub content: String,
16}
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct SplitDiffRow {
21 pub left: Option<SplitDiffCell>,
22 pub right: Option<SplitDiffCell>,
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct SplitDiffCell {
28 pub tag: DiffTag,
29 pub content: String,
30 pub line_number: Option<usize>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct DiffPreview {
36 pub lines: Vec<DiffLine>,
38 pub rows: Vec<SplitDiffRow>,
40 pub lang_hint: String,
41 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}