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<SplitDiffEntry>,
22 pub right: Option<SplitDiffEntry>,
23}
24
25#[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#[derive(Debug, Clone, PartialEq)]
41pub struct DiffPreview {
42 pub lines: Vec<DiffLine>,
44 pub rows: Vec<SplitDiffRow>,
46 pub lang_hint: String,
47 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}