Skip to main content

scooter_core/
diff.rs

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(); // group_by should never return an empty group
30        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        // Old content is unchanged but may be split into segments by the diff algorithm
222        let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
223        assert_eq!(old_text, "hello");
224
225        // New content should contain the newline in the diff
226        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        // Old content contains the newline
235        let old_text: String = old_actual.iter().map(|d| d.text.as_str()).collect();
236        assert_eq!(old_text, "hel\nlo");
237
238        // New content has it removed
239        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}