Skip to main content

diffler_core/
diff.rs

1//! Intra-line diff: byte ranges of changed regions between a paired
2//! old/new line, used for char-level emphasis on top of line diffs.
3
4use std::ops::Range;
5
6use similar::{ChangeTag, TextDiff};
7
8/// Byte ranges (into each input) that differ between the two lines.
9///
10/// Returns `(old_emphasis, new_emphasis)`. Adjacent ranges are merged.
11///
12/// ```
13/// use diffler_core::diff::intraline;
14///
15/// let (old, new) = intraline("if x < y:", "if x <= y:");
16/// assert!(old.is_empty());
17/// assert_eq!(new, vec![6..7]);
18/// ```
19pub fn intraline(old: &str, new: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
20    // graphemes, not chars: emphasis must never split a combining sequence
21    // or emoji cluster, or the TUI styles half a glyph
22    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        // "e\u{301}" is one grapheme; emphasis must cover it atomically
105        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}