1use std::ops::Range;
5
6use similar::{ChangeTag, TextDiff};
7
8pub fn intraline(old: &str, new: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
20 let diff = TextDiff::from_graphemes(old, new);
23 let mut old_ranges: Vec<Range<usize>> = Vec::new();
24 let mut new_ranges: Vec<Range<usize>> = Vec::new();
25 let mut old_pos = 0usize;
26 let mut new_pos = 0usize;
27
28 for change in diff.iter_all_changes() {
29 let len = change.value().len();
30 match change.tag() {
31 ChangeTag::Equal => {
32 old_pos += len;
33 new_pos += len;
34 }
35 ChangeTag::Delete => {
36 push_range(&mut old_ranges, old_pos..old_pos + len);
37 old_pos += len;
38 }
39 ChangeTag::Insert => {
40 push_range(&mut new_ranges, new_pos..new_pos + len);
41 new_pos += len;
42 }
43 }
44 }
45
46 (old_ranges, new_ranges)
47}
48
49fn push_range(ranges: &mut Vec<Range<usize>>, range: Range<usize>) {
50 if let Some(last) = ranges.last_mut()
51 && last.end == range.start
52 {
53 last.end = range.end;
54 return;
55 }
56 ranges.push(range);
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 #[test]
64 fn equal_lines_have_no_emphasis() {
65 let (old, new) = intraline("same line", "same line");
66 assert!(old.is_empty());
67 assert!(new.is_empty());
68 }
69
70 #[test]
71 fn ranges_are_in_bounds_and_ascending() {
72 let old = "if claims.expiry < now():";
73 let new = "if claims.expiry <= now() - LEEWAY:";
74 let (old_r, new_r) = intraline(old, new);
75 for r in &old_r {
76 assert!(r.end <= old.len());
77 }
78 let mut prev_end = 0;
79 for r in &new_r {
80 assert!(r.start >= prev_end && r.end <= new.len());
81 prev_end = r.end;
82 }
83 }
84
85 #[test]
86 fn insertion_is_emphasized_on_new_side_only() {
87 let (old, new) = intraline("session.touch()", "session.touch(now())");
88 assert!(old.is_empty());
89 let joined: String = new
90 .iter()
91 .map(|r| &"session.touch(now())"[r.clone()])
92 .collect();
93 assert_eq!(joined, "now()");
94 }
95
96 #[test]
97 fn adjacent_ranges_are_merged() {
98 let (_, new) = intraline("ab", "aXYb");
99 assert_eq!(new, vec![1..3]);
100 }
101
102 #[test]
103 fn combining_characters_stay_whole() {
104 let new_line = "cafe\u{301}";
106 let (_, new) = intraline("cafe", new_line);
107 for r in &new {
108 assert!(new_line.is_char_boundary(r.start), "range splits a char");
109 assert!(new_line.is_char_boundary(r.end), "range splits a char");
110 }
111 let joined: String = new.iter().map(|r| &new_line[r.clone()]).collect();
112 assert!(joined.contains('\u{301}'));
113 }
114
115 #[test]
116 fn empty_inputs() {
117 let (old, new) = intraline("", "");
118 assert!(old.is_empty());
119 assert!(new.is_empty());
120 }
121}