1use std::ops::Range;
4
5use similar::{ChangeTag, InlineChangeMode, InlineChangeOptions, TextDiff};
6
7const MIN_INLINE_RATIO: f32 = 0.5;
9
10const MAX_GAP_CHARS: usize = 2;
13
14pub 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 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
66fn 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}