use std::ops::Range;
use similar::{ChangeTag, InlineChangeMode, InlineChangeOptions, TextDiff};
const MIN_INLINE_RATIO: f32 = 0.5;
const MAX_GAP_CHARS: usize = 2;
pub fn intraline(old: &str, new: &str) -> (Vec<Range<usize>>, Vec<Range<usize>>) {
let diff = TextDiff::from_lines(old, new);
let mut options = InlineChangeOptions::new();
options
.mode(InlineChangeMode::UnicodeWords)
.semantic_cleanup(true)
.min_ratio(MIN_INLINE_RATIO);
let mut old_ranges: Vec<Range<usize>> = Vec::new();
let mut new_ranges: Vec<Range<usize>> = Vec::new();
let (mut old_pos, mut new_pos) = (0usize, 0usize);
for op in diff.ops() {
for change in diff.iter_inline_changes_with_options(op, options) {
let piece_len: usize = change.values().iter().map(|(_, piece)| piece.len()).sum();
let (ranges, pos) = match change.tag() {
ChangeTag::Delete => (&mut old_ranges, &mut old_pos),
ChangeTag::Insert => (&mut new_ranges, &mut new_pos),
ChangeTag::Equal => {
old_pos += piece_len;
new_pos += piece_len;
continue;
}
};
for &(emphasized, piece) in change.values() {
if emphasized {
ranges.push(*pos..*pos + piece.len());
}
*pos += piece.len();
}
}
}
(
coalesce(old, drop_indent_only(old, old_ranges)),
coalesce(new, drop_indent_only(new, new_ranges)),
)
}
fn drop_indent_only(text: &str, ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
ranges
.into_iter()
.filter(|r| {
let ws_only = text.get(r.clone()).is_some_and(|s| s.trim().is_empty());
let in_indent = text.get(..r.start).is_some_and(|s| s.trim().is_empty());
!(ws_only && in_indent)
})
.collect()
}
fn coalesce(text: &str, ranges: Vec<Range<usize>>) -> Vec<Range<usize>> {
let mut out: Vec<Range<usize>> = Vec::new();
for range in ranges {
if let Some(last) = out.last_mut()
&& text
.get(last.end..range.start)
.is_some_and(|gap| gap.chars().count() <= MAX_GAP_CHARS)
{
last.end = range.end;
continue;
}
out.push(range);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_lines_have_no_emphasis() {
let (old, new) = intraline("same line", "same line");
assert!(old.is_empty());
assert!(new.is_empty());
}
#[test]
fn ranges_are_in_bounds_and_ascending() {
let old = "if claims.expiry < now():";
let new = "if claims.expiry <= now() - LEEWAY:";
let (old_r, new_r) = intraline(old, new);
for r in &old_r {
assert!(r.end <= old.len());
}
let mut prev_end = 0;
for r in &new_r {
assert!(r.start >= prev_end && r.end <= new.len());
prev_end = r.end;
}
}
#[test]
fn insertion_is_emphasized_on_new_side_only() {
let (old, new) = intraline("session.touch()", "session.touch(now())");
assert!(old.is_empty());
let joined: String = new
.iter()
.map(|r| &"session.touch(now())"[r.clone()])
.collect();
assert_eq!(joined, "now()");
}
#[test]
fn changed_word_is_emphasized_whole_never_fragmented() {
let old = "npm run lint";
let new = "bun run lint";
let (old_r, new_r) = intraline(old, new);
assert_eq!(old_r, vec![0..3]);
assert_eq!(new_r, vec![0..3]);
}
#[test]
fn prose_edit_emphasizes_only_the_changed_words() {
let old = "runs `better-auth migrate` against src";
let new = "runs `auth migrate` against src";
let (old_r, new_r) = intraline(old, new);
let joined: String = old_r.iter().map(|r| &old[r.clone()]).collect();
assert_eq!(joined, "better-");
assert!(new_r.is_empty(), "new side only lost words: {new_r:?}");
}
#[test]
fn dissimilar_lines_fall_back_to_no_emphasis() {
let (old, new) = intraline(
"npm run migrate -w services/auth",
"bun run --filter '@syte-tech/auth-service' migrate",
);
assert!(old.is_empty(), "{old:?}");
assert!(new.is_empty(), "{new:?}");
}
#[test]
fn tiny_gaps_between_runs_merge_into_one_span() {
let text = "ab";
let merged = coalesce(text, vec![0..1, 1..2]);
assert_eq!(merged, vec![0..2]);
let text = "a--b";
let merged = coalesce(text, vec![0..1, 3..4]);
assert_eq!(merged, vec![0..4]);
let text = "a---b";
let merged = coalesce(text, vec![0..1, 4..5]);
assert_eq!(merged, vec![0..1, 4..5]);
}
#[test]
fn combining_characters_stay_whole() {
let old_line = "drink cafe daily";
let new_line = "drink cafe\u{301} daily";
let (_, new) = intraline(old_line, new_line);
for r in &new {
assert!(new_line.is_char_boundary(r.start), "range splits a char");
assert!(new_line.is_char_boundary(r.end), "range splits a char");
}
let joined: String = new.iter().map(|r| &new_line[r.clone()]).collect();
assert!(joined.contains('\u{301}'), "emphasis: {new:?}");
}
#[test]
fn indentation_only_changes_carry_no_emphasis() {
let (old, new) = intraline(" openAPI(),", " openAPI(),");
assert!(old.is_empty(), "{old:?}");
assert!(new.is_empty(), "{new:?}");
}
#[test]
fn whitespace_shift_beside_a_real_edit_keeps_the_edit() {
let old = "foo bar";
let new = "foo bar baz";
let (old_r, new_r) = intraline(old, new);
assert!(old_r.iter().all(|r| r.start >= 3), "{old_r:?}");
let joined: String = new_r.iter().map(|r| &new[r.clone()]).collect();
assert!(joined.contains("baz"), "the added word survives: {new_r:?}");
}
#[test]
fn trailing_and_midline_whitespace_edits_stay_visible() {
let (_, new_r) = intraline("foo();", "foo(); ");
assert_eq!(new_r, vec![6..7], "trailing space stays marked");
let (old_r, new_r) = intraline("foo\tbar();", "foo bar();");
assert!(
!old_r.is_empty() && !new_r.is_empty(),
"tab swap stays marked"
);
}
#[test]
fn empty_inputs() {
let (old, new) = intraline("", "");
assert!(old.is_empty());
assert!(new.is_empty());
}
#[test]
fn embedded_carriage_returns_keep_offsets_global() {
let old = "alpha\rfoo bar baz";
let new = "alpha\rfoo QUX baz";
let (old_r, new_r) = intraline(old, new);
let covered: String = old_r.iter().map(|r| &old[r.clone()]).collect();
assert_eq!(covered, "bar", "{old_r:?}");
let covered: String = new_r.iter().map(|r| &new[r.clone()]).collect();
assert_eq!(covered, "QUX", "{new_r:?}");
let old = "héé\rfoo bar baz";
let new = "héé\rfoo QUX baz";
let (old_r, new_r) = intraline(old, new);
for r in old_r.iter().chain(&new_r) {
assert!(old.is_char_boundary(r.start) && old.is_char_boundary(r.end));
}
let covered: String = new_r.iter().map(|r| &new[r.clone()]).collect();
assert_eq!(covered, "QUX", "{new_r:?}");
let old = "aa bb cc\rdd ee ff";
let new = "aa XX cc\rdd YY ff";
let (_, new_r) = intraline(old, new);
let covered: Vec<&str> = new_r.iter().map(|r| &new[r.clone()]).collect();
assert_eq!(covered, ["XX", "YY"], "{new_r:?}");
}
}