1use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::algo::{self, Edit};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum LineKind {
13 Normal,
14 Add,
15 Del,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct DiffLine {
21 pub kind: LineKind,
22 pub old_line: Option<usize>,
24 pub new_line: Option<usize>,
26 pub text: String,
28}
29
30impl DiffLine {
31 pub fn prefixed(&self) -> String {
33 let p = match self.kind {
34 LineKind::Normal => ' ',
35 LineKind::Add => '+',
36 LineKind::Del => '-',
37 };
38 format!("{p}{}", self.text)
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct Hunk {
45 pub old_start: usize,
46 pub old_lines: usize,
47 pub new_start: usize,
48 pub new_lines: usize,
49 pub lines: Vec<DiffLine>,
50}
51
52impl Hunk {
53 pub fn header(&self) -> String {
55 format!(
56 "@@ -{},{} +{},{} @@",
57 self.old_start, self.old_lines, self.new_start, self.new_lines
58 )
59 }
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct UnifiedDiff {
65 pub hunks: Vec<Hunk>,
66 pub additions: usize,
67 pub deletions: usize,
68}
69
70impl UnifiedDiff {
71 pub fn text(&self) -> String {
73 let mut out = String::new();
74 for h in &self.hunks {
75 if !out.is_empty() {
76 out.push('\n');
77 }
78 out.push_str(&h.header());
79 for l in &h.lines {
80 out.push('\n');
81 out.push_str(&l.prefixed());
82 }
83 }
84 out
85 }
86}
87
88pub fn unified_diff(old: &str, new: &str, context: usize) -> UnifiedDiff {
90 let ol: Vec<&str> = old.lines().collect();
91 let nl: Vec<&str> = new.lines().collect();
92 let mut ids: HashMap<&str, u32> = HashMap::new();
93 let mut ni = Vec::with_capacity(nl.len());
94 let mut oi = Vec::with_capacity(ol.len());
95 for (ls, out) in [(&ol, &mut oi), (&nl, &mut ni)] {
96 for &l in ls.iter() {
97 let n = ids.len() as u32;
98 out.push(*ids.entry(l).or_insert(n));
99 }
100 }
101 let edits = algo::diff(&oi, &ni);
102
103 let mut out = UnifiedDiff::default();
104 let changed: Vec<usize> = edits
105 .iter()
106 .enumerate()
107 .filter(|(_, e)| !matches!(e, Edit::Equal(..)))
108 .map(|(p, _)| p)
109 .collect();
110 let Some(&first) = changed.first() else {
111 return out;
112 };
113 let mut groups: Vec<(usize, usize)> = vec![(first, first)];
115 for &p in &changed[1..] {
116 let last = groups.last_mut().expect("non-empty");
117 if p - last.1 - 1 > 2 * context {
118 groups.push((p, p));
119 } else {
120 last.1 = p;
121 }
122 }
123 let (mut before_old, mut before_new) = (Vec::with_capacity(edits.len()), Vec::new());
125 let (mut o, mut n) = (0usize, 0usize);
126 for e in &edits {
127 before_old.push(o);
128 before_new.push(n);
129 match e {
130 Edit::Equal(..) => {
131 o += 1;
132 n += 1;
133 }
134 Edit::Delete(_) => o += 1,
135 Edit::Insert(_) => n += 1,
136 }
137 }
138 for (start, end) in groups {
139 let lo = start.saturating_sub(context);
140 let hi = (end + context).min(edits.len() - 1);
141 let mut lines = Vec::with_capacity(hi - lo + 1);
142 let (mut old_lines, mut new_lines) = (0, 0);
143 for e in &edits[lo..=hi] {
144 let line = match *e {
145 Edit::Equal(i, j) => {
146 old_lines += 1;
147 new_lines += 1;
148 DiffLine {
149 kind: LineKind::Normal,
150 old_line: Some(i + 1),
151 new_line: Some(j + 1),
152 text: ol[i].to_owned(),
153 }
154 }
155 Edit::Delete(i) => {
156 old_lines += 1;
157 out.deletions += 1;
158 DiffLine {
159 kind: LineKind::Del,
160 old_line: Some(i + 1),
161 new_line: None,
162 text: ol[i].to_owned(),
163 }
164 }
165 Edit::Insert(j) => {
166 new_lines += 1;
167 out.additions += 1;
168 DiffLine {
169 kind: LineKind::Add,
170 old_line: None,
171 new_line: Some(j + 1),
172 text: nl[j].to_owned(),
173 }
174 }
175 };
176 lines.push(line);
177 }
178 let start_of = |before: usize, count: usize| if count == 0 { before } else { before + 1 };
180 out.hunks.push(Hunk {
181 old_start: start_of(before_old[lo], old_lines),
182 old_lines,
183 new_start: start_of(before_new[lo], new_lines),
184 new_lines,
185 lines,
186 });
187 }
188 out
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn identical_texts_have_no_hunks() {
197 let d = unified_diff("a\nb\n", "a\nb", 3);
198 assert!(d.hunks.is_empty());
199 assert_eq!(d.text(), "");
200 }
201
202 #[test]
203 fn single_line_change() {
204 let d = unified_diff("old content\n", "new content\n", 3);
205 assert_eq!(d.text(), "@@ -1,1 +1,1 @@\n-old content\n+new content");
206 assert_eq!((d.additions, d.deletions), (1, 1));
207 assert_eq!(d.hunks[0].lines[0].old_line, Some(1));
208 assert_eq!(d.hunks[0].lines[1].new_line, Some(1));
209 }
210
211 #[test]
212 fn context_lines_and_line_numbers() {
213 let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n";
214 let new = "1\n2\n3\n4\n5\nsix\n7\n8\n9\n10\n";
215 let d = unified_diff(old, new, 2);
216 assert_eq!(d.text(), "@@ -4,5 +4,5 @@\n 4\n 5\n-6\n+six\n 7\n 8");
217 }
218
219 #[test]
220 fn distant_changes_split_into_hunks_and_near_ones_merge() {
221 let old: String = (1..=20).map(|i| format!("l{i}\n")).collect();
222 let new = old.replace("l2\n", "L2\n").replace("l18\n", "L18\n");
223 let d = unified_diff(&old, &new, 3);
224 assert_eq!(d.hunks.len(), 2);
225 assert_eq!(d.hunks[0].header(), "@@ -1,5 +1,5 @@");
226 assert_eq!(d.hunks[1].header(), "@@ -15,6 +15,6 @@");
227 let near = old.replace("l2\n", "L2\n").replace("l8\n", "L8\n");
228 assert_eq!(unified_diff(&old, &near, 3).hunks.len(), 1);
229 }
230
231 #[test]
232 fn pure_insertions_and_deletions() {
233 let d = unified_diff("", "a\nb\n", 3);
234 assert_eq!(d.text(), "@@ -0,0 +1,2 @@\n+a\n+b");
235 let d = unified_diff("a\nb\n", "", 3);
236 assert_eq!(d.text(), "@@ -1,2 +0,0 @@\n-a\n-b");
237 let d = unified_diff("a\nb\n", "a\nb\nc\n", 0);
238 assert_eq!(d.text(), "@@ -2,0 +3,1 @@\n+c");
239 }
240}