Skip to main content

diffler_core/
diff.rs

1//! Intra-line diff: byte ranges that differ between a paired old/new line.
2
3use std::ops::Range;
4
5use similar::{ChangeTag, InlineChangeMode, InlineChangeOptions, TextDiff};
6
7/// Below this token similarity we emphasize nothing.
8const MIN_INLINE_RATIO: f32 = 0.5;
9
10/// We merge runs this close before `pairing::MAX_EMPHASIS_RUNS` counts them,
11/// so we tune the two together.
12const MAX_GAP_CHARS: usize = 2;
13
14/// `(old_emphasis, new_emphasis)` byte ranges, near-adjacent runs merged.
15///
16/// We diff UAX #29 word tokens so unrelated tokens never share a stray
17/// letter (`npm` → `bun` is a whole-token swap).
18///
19/// ```
20/// use diffler_core::diff::intraline;
21///
22/// let (old, new) = intraline("if x < y:", "if x <= y:");
23/// assert!(old.is_empty());
24/// assert_eq!(new, vec![6..7]);
25/// ```
26pub fn intraline(old: &str, new: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
27    let diff = TextDiff::from_lines(old, new);
28    let mut options = InlineChangeOptions::new();
29    options
30        .mode(InlineChangeMode::UnicodeWords)
31        .semantic_cleanup(true)
32        .min_ratio(MIN_INLINE_RATIO);
33
34    // `from_lines` splits on embedded `\r` too, so we keep one position per
35    // side across segments to emit offsets into the whole line
36    let mut old_ranges: Vec<Range<usize>> = Vec::new();
37    let mut new_ranges: Vec<Range<usize>> = Vec::new();
38    let (mut old_pos, mut new_pos) = (0usize, 0usize);
39    for op in diff.ops() {
40        for change in diff.iter_inline_changes_with_options(op, options) {
41            let piece_len: usize = change.values().iter().map(|(_, piece)| piece.len()).sum();
42            let (ranges, pos) = match change.tag() {
43                ChangeTag::Delete => (&mut old_ranges, &mut old_pos),
44                ChangeTag::Insert => (&mut new_ranges, &mut new_pos),
45                ChangeTag::Equal => {
46                    old_pos += piece_len;
47                    new_pos += piece_len;
48                    continue;
49                }
50            };
51            for &(emphasized, piece) in change.values() {
52                if emphasized {
53                    ranges.push(*pos..*pos + piece.len());
54                }
55                *pos += piece.len();
56            }
57        }
58    }
59
60    (
61        coalesce(old, drop_indent_only(old, old_ranges)),
62        coalesce(new, drop_indent_only(new, new_ranges)),
63    )
64}
65
66/// Drop emphasis on leading indentation, matching the AST engine. We keep
67/// whitespace changes after content, since a trailing space or a tab swap is
68/// invisible without the highlight.
69fn drop_indent_only(text: &str, ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
70    ranges
71        .into_iter()
72        .filter(|r| {
73            let ws_only = text.get(r.clone()).is_some_and(|s| s.trim().is_empty());
74            let in_indent = text.get(..r.start).is_some_and(|s| s.trim().is_empty());
75            !(ws_only && in_indent)
76        })
77        .collect()
78}
79
80fn coalesce(text: &str, ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
81    let mut out: Vec<Range<usize>> = Vec::new();
82    for range in ranges {
83        if let Some(last) = out.last_mut()
84            && text
85                .get(last.end..range.start)
86                .is_some_and(|gap| gap.chars().count() <= MAX_GAP_CHARS)
87        {
88            last.end = range.end;
89            continue;
90        }
91        out.push(range);
92    }
93    out
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn equal_lines_have_no_emphasis() {
102        let (old, new) = intraline("same line", "same line");
103        assert!(old.is_empty());
104        assert!(new.is_empty());
105    }
106
107    #[test]
108    fn ranges_are_in_bounds_and_ascending() {
109        let old = "if claims.expiry < now():";
110        let new = "if claims.expiry <= now() - LEEWAY:";
111        let (old_r, new_r) = intraline(old, new);
112        for r in &old_r {
113            assert!(r.end <= old.len());
114        }
115        let mut prev_end = 0;
116        for r in &new_r {
117            assert!(r.start >= prev_end && r.end <= new.len());
118            prev_end = r.end;
119        }
120    }
121
122    #[test]
123    fn insertion_is_emphasized_on_new_side_only() {
124        let (old, new) = intraline("session.touch()", "session.touch(now())");
125        assert!(old.is_empty());
126        let joined: String = new
127            .iter()
128            .map(|r| &"session.touch(now())"[r.clone()])
129            .collect();
130        assert_eq!(joined, "now()");
131    }
132
133    #[test]
134    fn changed_word_is_emphasized_whole_never_fragmented() {
135        let old = "npm run lint";
136        let new = "bun run lint";
137        let (old_r, new_r) = intraline(old, new);
138        assert_eq!(old_r, vec![0..3]);
139        assert_eq!(new_r, vec![0..3]);
140    }
141
142    #[test]
143    fn prose_edit_emphasizes_only_the_changed_words() {
144        let old = "runs `better-auth migrate` against src";
145        let new = "runs `auth migrate` against src";
146        let (old_r, new_r) = intraline(old, new);
147        let joined: String = old_r.iter().map(|r| &old[r.clone()]).collect();
148        assert_eq!(joined, "better-");
149        assert!(new_r.is_empty(), "new side only lost words: {new_r:?}");
150    }
151
152    #[test]
153    fn dissimilar_lines_fall_back_to_no_emphasis() {
154        let (old, new) = intraline(
155            "npm run migrate -w services/auth",
156            "bun run --filter '@acme/auth-service' migrate",
157        );
158        assert!(old.is_empty(), "{old:?}");
159        assert!(new.is_empty(), "{new:?}");
160    }
161
162    #[test]
163    fn tiny_gaps_between_runs_merge_into_one_span() {
164        let text = "ab";
165        let merged = coalesce(text, vec![0..1, 1..2]);
166        assert_eq!(merged, vec![0..2]);
167        let text = "a--b";
168        let merged = coalesce(text, vec![0..1, 3..4]);
169        assert_eq!(merged, vec![0..4]);
170        let text = "a---b";
171        let merged = coalesce(text, vec![0..1, 4..5]);
172        assert_eq!(merged, vec![0..1, 4..5]);
173    }
174
175    #[test]
176    fn combining_characters_stay_whole() {
177        let old_line = "drink cafe daily";
178        let new_line = "drink cafe\u{301} daily";
179        let (_, new) = intraline(old_line, new_line);
180        for r in &new {
181            assert!(new_line.is_char_boundary(r.start), "range splits a char");
182            assert!(new_line.is_char_boundary(r.end), "range splits a char");
183        }
184        let joined: String = new.iter().map(|r| &new_line[r.clone()]).collect();
185        assert!(joined.contains('\u{301}'), "emphasis: {new:?}");
186    }
187
188    #[test]
189    fn indentation_only_changes_carry_no_emphasis() {
190        let (old, new) = intraline("        openAPI(),", "            openAPI(),");
191        assert!(old.is_empty(), "{old:?}");
192        assert!(new.is_empty(), "{new:?}");
193    }
194
195    #[test]
196    fn whitespace_shift_beside_a_real_edit_keeps_the_edit() {
197        let old = "foo  bar";
198        let new = "foo bar baz";
199        let (old_r, new_r) = intraline(old, new);
200        assert!(old_r.iter().all(|r| r.start >= 3), "{old_r:?}");
201        let joined: String = new_r.iter().map(|r| &new[r.clone()]).collect();
202        assert!(joined.contains("baz"), "the added word survives: {new_r:?}");
203    }
204
205    #[test]
206    fn trailing_and_midline_whitespace_edits_stay_visible() {
207        let (_, new_r) = intraline("foo();", "foo(); ");
208        assert_eq!(new_r, vec![6..7], "trailing space stays marked");
209        let (old_r, new_r) = intraline("foo\tbar();", "foo    bar();");
210        assert!(
211            !old_r.is_empty() && !new_r.is_empty(),
212            "tab swap stays marked"
213        );
214    }
215
216    #[test]
217    fn empty_inputs() {
218        let (old, new) = intraline("", "");
219        assert!(old.is_empty());
220        assert!(new.is_empty());
221    }
222
223    #[test]
224    fn embedded_carriage_returns_keep_offsets_global() {
225        let old = "alpha\rfoo bar baz";
226        let new = "alpha\rfoo QUX baz";
227        let (old_r, new_r) = intraline(old, new);
228        let covered: String = old_r.iter().map(|r| &old[r.clone()]).collect();
229        assert_eq!(covered, "bar", "{old_r:?}");
230        let covered: String = new_r.iter().map(|r| &new[r.clone()]).collect();
231        assert_eq!(covered, "QUX", "{new_r:?}");
232
233        let old = "héé\rfoo bar baz";
234        let new = "héé\rfoo QUX baz";
235        let (old_r, new_r) = intraline(old, new);
236        for r in old_r.iter().chain(&new_r) {
237            assert!(old.is_char_boundary(r.start) && old.is_char_boundary(r.end));
238        }
239        let covered: String = new_r.iter().map(|r| &new[r.clone()]).collect();
240        assert_eq!(covered, "QUX", "{new_r:?}");
241
242        let old = "aa bb cc\rdd ee ff";
243        let new = "aa XX cc\rdd YY ff";
244        let (_, new_r) = intraline(old, new);
245        let covered: Vec<&str> = new_r.iter().map(|r| &new[r.clone()]).collect();
246        assert_eq!(covered, ["XX", "YY"], "{new_r:?}");
247    }
248}