1use similar::{Change, ChangeTag, TextDiff};
2
3use crate::utils::group_by;
4
5#[derive(Debug, PartialEq, Eq, Clone)]
6pub enum DiffColour {
7 Red,
8 Green,
9 Black,
10}
11
12#[derive(Debug, PartialEq, Eq, Clone)]
13pub struct Diff {
14 pub text: String,
15 pub fg_colour: DiffColour,
16 pub bg_colour: Option<DiffColour>,
17}
18
19pub fn line_diff<'a>(old_line: &'a str, new_line: &'a str) -> (Vec<Diff>, Vec<Diff>) {
20 let diff = TextDiff::configure()
21 .algorithm(similar::Algorithm::Myers)
22 .timeout(std::time::Duration::from_millis(100))
23 .diff_chars(old_line, new_line);
24
25 let mut old_spans = Vec::new();
26 let mut new_spans = Vec::new();
27
28 for change_group in group_by(diff.iter_all_changes(), |c1, c2| c1.tag() == c2.tag()) {
29 let first_change = change_group.first().unwrap(); let text = change_group.iter().map(Change::value).collect();
31 match first_change.tag() {
32 ChangeTag::Delete => {
33 old_spans.push(Diff {
34 text,
35 fg_colour: DiffColour::Black,
36 bg_colour: Some(DiffColour::Red),
37 });
38 }
39 ChangeTag::Insert => {
40 new_spans.push(Diff {
41 text,
42 fg_colour: DiffColour::Black,
43 bg_colour: Some(DiffColour::Green),
44 });
45 }
46 ChangeTag::Equal => {
47 old_spans.push(Diff {
48 text: text.clone(),
49 fg_colour: DiffColour::Red,
50 bg_colour: None,
51 });
52 new_spans.push(Diff {
53 text,
54 fg_colour: DiffColour::Green,
55 bg_colour: None,
56 });
57 }
58 }
59 }
60
61 (old_spans, new_spans)
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn test_identical_lines() {
70 let (old_actual, new_actual) = line_diff("hello", "hello");
71
72 let old_expected = vec![Diff {
73 text: "hello".to_owned(),
74 fg_colour: DiffColour::Red,
75 bg_colour: None,
76 }];
77
78 let new_expected = vec![Diff {
79 text: "hello".to_owned(),
80 fg_colour: DiffColour::Green,
81 bg_colour: None,
82 }];
83
84 assert_eq!(old_expected, old_actual);
85 assert_eq!(new_expected, new_actual);
86 }
87
88 #[test]
89 fn test_single_char_difference() {
90 let (old_actual, new_actual) = line_diff("hello", "hallo");
91
92 let old_expected = vec![
93 Diff {
94 text: "h".to_owned(),
95 fg_colour: DiffColour::Red,
96 bg_colour: None,
97 },
98 Diff {
99 text: "e".to_owned(),
100 fg_colour: DiffColour::Black,
101 bg_colour: Some(DiffColour::Red),
102 },
103 Diff {
104 text: "llo".to_owned(),
105 fg_colour: DiffColour::Red,
106 bg_colour: None,
107 },
108 ];
109
110 let new_expected = vec![
111 Diff {
112 text: "h".to_owned(),
113 fg_colour: DiffColour::Green,
114 bg_colour: None,
115 },
116 Diff {
117 text: "a".to_owned(),
118 fg_colour: DiffColour::Black,
119 bg_colour: Some(DiffColour::Green),
120 },
121 Diff {
122 text: "llo".to_owned(),
123 fg_colour: DiffColour::Green,
124 bg_colour: None,
125 },
126 ];
127
128 assert_eq!(old_expected, old_actual);
129 assert_eq!(new_expected, new_actual);
130 }
131
132 #[test]
133 fn test_completely_different_strings() {
134 let (old_actual, new_actual) = line_diff("foo", "bar");
135
136 let old_expected = vec![Diff {
137 text: "foo".to_owned(),
138 fg_colour: DiffColour::Black,
139 bg_colour: Some(DiffColour::Red),
140 }];
141
142 let new_expected = vec![Diff {
143 text: "bar".to_owned(),
144 fg_colour: DiffColour::Black,
145 bg_colour: Some(DiffColour::Green),
146 }];
147
148 assert_eq!(old_expected, old_actual);
149 assert_eq!(new_expected, new_actual);
150 }
151
152 #[test]
153 fn test_empty_strings() {
154 let (old_actual, new_actual) = line_diff("", "");
155
156 let old_expected: Vec<Diff> = vec![];
157 let new_expected: Vec<Diff> = vec![];
158
159 assert_eq!(old_expected, old_actual);
160 assert_eq!(new_expected, new_actual);
161 }
162
163 #[test]
164 fn test_addition_at_end() {
165 let (old_actual, new_actual) = line_diff("hello", "hello!");
166
167 let old_expected = vec![Diff {
168 text: "hello".to_owned(),
169 fg_colour: DiffColour::Red,
170 bg_colour: None,
171 }];
172
173 let new_expected = vec![
174 Diff {
175 text: "hello".to_owned(),
176 fg_colour: DiffColour::Green,
177 bg_colour: None,
178 },
179 Diff {
180 text: "!".to_owned(),
181 fg_colour: DiffColour::Black,
182 bg_colour: Some(DiffColour::Green),
183 },
184 ];
185
186 assert_eq!(old_expected, old_actual);
187 assert_eq!(new_expected, new_actual);
188 }
189
190 #[test]
191 fn test_addition_at_start() {
192 let (old_actual, new_actual) = line_diff("hello", "!hello");
193
194 let old_expected = vec![Diff {
195 text: "hello".to_owned(),
196 fg_colour: DiffColour::Red,
197 bg_colour: None,
198 }];
199
200 let new_expected = vec![
201 Diff {
202 text: "!".to_owned(),
203 fg_colour: DiffColour::Black,
204 bg_colour: Some(DiffColour::Green),
205 },
206 Diff {
207 text: "hello".to_owned(),
208 fg_colour: DiffColour::Green,
209 bg_colour: None,
210 },
211 ];
212
213 assert_eq!(old_expected, old_actual);
214 assert_eq!(new_expected, new_actual);
215 }
216
217 #[test]
218 fn test_newline_in_new_content() {
219 let (old_actual, new_actual) = line_diff("hello", "hel\nlo");
220
221 let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
223 assert_eq!(old_text, "hello");
224
225 let new_text: String = new_actual.iter().map(|d| d.text.as_str()).collect();
227 assert_eq!(new_text, "hel\nlo");
228 }
229
230 #[test]
231 fn test_newline_in_old_content() {
232 let (old_actual, new_actual) = line_diff("hel\nlo", "hello");
233
234 let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
236 assert_eq!(old_text, "hel\nlo");
237
238 let new_text: String = new_actual.iter().map(|d| d.text.as_str()).collect();
240 assert_eq!(new_text, "hello");
241 }
242
243 #[test]
244 fn test_replacement_with_only_newlines() {
245 let (old_actual, new_actual) = line_diff("abc", "\n\n");
246
247 let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
248 assert_eq!(old_text, "abc");
249
250 let new_text: String = new_actual.iter().map(|d| d.text.as_str()).collect();
251 assert_eq!(new_text, "\n\n");
252 }
253
254 #[test]
255 fn test_unicode_multibyte_chars() {
256 let (old_actual, new_actual) = line_diff("héllo", "hëllo");
257
258 let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
259 assert_eq!(old_text, "héllo");
260
261 let new_text: String = new_actual.iter().map(|d| d.text.as_str()).collect();
262 assert_eq!(new_text, "hëllo");
263 }
264
265 #[test]
266 fn test_unicode_cjk_characters() {
267 let (old_actual, new_actual) = line_diff("世界", "世間");
268
269 let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
270 assert_eq!(old_text, "世界");
271
272 let new_text: String = new_actual.iter().map(|d| d.text.as_str()).collect();
273 assert_eq!(new_text, "世間");
274 }
275
276 #[test]
277 fn test_empty_to_nonempty() {
278 let (old_actual, new_actual) = line_diff("", "hello");
279
280 assert!(old_actual.is_empty());
281
282 assert_eq!(new_actual.len(), 1);
283 assert_eq!(new_actual[0].text, "hello");
284 assert_eq!(new_actual[0].bg_colour, Some(DiffColour::Green));
285 }
286
287 #[test]
288 fn test_nonempty_to_empty() {
289 let (old_actual, new_actual) = line_diff("hello", "");
290
291 assert_eq!(old_actual.len(), 1);
292 assert_eq!(old_actual[0].text, "hello");
293 assert_eq!(old_actual[0].bg_colour, Some(DiffColour::Red));
294
295 assert!(new_actual.is_empty());
296 }
297
298 #[test]
299 fn test_crlf_in_content() {
300 let (old_actual, new_actual) = line_diff("hello", "hel\r\nlo");
301
302 let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
303 assert_eq!(old_text, "hello");
304
305 let new_text: String = new_actual.iter().map(|d| d.text.as_str()).collect();
306 assert_eq!(new_text, "hel\r\nlo");
307 }
308}