use std::collections::HashMap;
use similar::{Algorithm, DiffOp, capture_diff_slices};
use unicode_width::UnicodeWidthStr;
use super::RowKey;
use crate::model::{DiffFile, DiffRowKind};
const MAX_LINE_DISTANCE: f64 = 0.6;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct EmphSpan {
pub start: usize,
pub end: usize,
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn tokenize(line: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut word_start: Option<usize> = None;
for (i, c) in line.char_indices() {
if is_word(c) {
word_start.get_or_insert(i);
} else {
if let Some(ws) = word_start.take() {
out.push((ws, i));
}
out.push((i, i + c.len_utf8()));
}
}
if let Some(ws) = word_start.take() {
out.push((ws, line.len()));
}
out
}
fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
fn section_width(line: &str, start: usize, end: usize) -> usize {
display_width(line[start..end].trim())
}
fn coalesce(mut spans: Vec<EmphSpan>) -> Vec<EmphSpan> {
spans.sort_unstable_by_key(|s| s.start);
let mut out: Vec<EmphSpan> = Vec::new();
for s in spans {
match out.last_mut() {
Some(last) if s.start <= last.end => last.end = last.end.max(s.end),
_ => out.push(s),
}
}
out
}
fn diff_pair(minus: &str, plus: &str) -> (Vec<EmphSpan>, Vec<EmphSpan>, f64) {
let mt = tokenize(minus);
let pt = tokenize(plus);
let ms: Vec<&str> = mt.iter().map(|&(s, e)| &minus[s..e]).collect();
let ps: Vec<&str> = pt.iter().map(|&(s, e)| &plus[s..e]).collect();
let mut del = Vec::new();
let mut ins = Vec::new();
let (mut numer, mut denom) = (0usize, 0usize);
let span = |ranges: &[(usize, usize)], i: usize, len: usize| -> (usize, usize) {
(ranges[i].0, ranges[i + len - 1].1)
};
for op in capture_diff_slices(Algorithm::Myers, &ms, &ps) {
match op {
DiffOp::Equal { old_index, len, .. } => {
let (s, e) = span(&mt, old_index, len);
denom += 2 * section_width(minus, s, e);
}
DiffOp::Delete {
old_index, old_len, ..
} => {
let (s, e) = span(&mt, old_index, old_len);
let w = section_width(minus, s, e);
numer += w;
denom += w;
if w > 0 {
del.push(EmphSpan { start: s, end: e });
}
}
DiffOp::Insert {
new_index, new_len, ..
} => {
let (s, e) = span(&pt, new_index, new_len);
let w = section_width(plus, s, e);
numer += w;
denom += w;
if w > 0 {
ins.push(EmphSpan { start: s, end: e });
}
}
DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => {
let (ms_, me_) = span(&mt, old_index, old_len);
let wo = section_width(minus, ms_, me_);
numer += wo;
denom += wo;
if wo > 0 {
del.push(EmphSpan {
start: ms_,
end: me_,
});
}
let (ps_, pe_) = span(&pt, new_index, new_len);
let wn = section_width(plus, ps_, pe_);
numer += wn;
denom += wn;
if wn > 0 {
ins.push(EmphSpan {
start: ps_,
end: pe_,
});
}
}
}
}
let distance = if denom > 0 {
numer as f64 / denom as f64
} else {
0.0
};
(coalesce(del), coalesce(ins), distance)
}
pub fn emphasis_file(file: &DiffFile) -> HashMap<RowKey, Vec<EmphSpan>> {
let mut out = HashMap::new();
if file.is_binary || file.is_submodule || file.is_mode_only {
return out;
}
for (h, hunk) in file.hunks.iter().enumerate() {
let mut minus: Vec<(usize, &str)> = Vec::new();
let mut plus: Vec<(usize, &str)> = Vec::new();
for (r, row) in hunk.rows.iter().enumerate() {
match row.kind {
DiffRowKind::Removed => {
if !plus.is_empty() {
flush_block(h, &minus, &plus, &mut out);
minus.clear();
plus.clear();
}
minus.push((r, &row.text));
}
DiffRowKind::Added => plus.push((r, &row.text)),
DiffRowKind::Context => {
flush_block(h, &minus, &plus, &mut out);
minus.clear();
plus.clear();
}
}
}
flush_block(h, &minus, &plus, &mut out); }
out
}
fn flush_block(
h: usize,
minus: &[(usize, &str)],
plus: &[(usize, &str)],
out: &mut HashMap<RowKey, Vec<EmphSpan>>,
) {
let mut plus_index = 0; for &(mr, mline) in minus {
for (considered, &(pr, pline)) in plus[plus_index..].iter().enumerate() {
let (del, ins, dist) = diff_pair(mline, pline);
if dist <= MAX_LINE_DISTANCE {
if !del.is_empty() {
out.insert((h, mr), del);
}
if !ins.is_empty() {
out.insert((h, pr), ins);
}
plus_index += considered + 1;
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DiffFile, DiffRow, DiffRowKind, FileId, FileStatus, HunkId, ReviewHunk};
fn row(kind: DiffRowKind, text: &str) -> DiffRow {
DiffRow {
kind,
old_line: None,
new_line: None,
text: text.to_owned(),
}
}
fn file_with(new_path: Option<&str>, rows: Vec<DiffRow>) -> DiffFile {
DiffFile {
id: FileId::new("file:a"),
status: FileStatus::Modified,
old_path: new_path.map(str::to_owned),
new_path: new_path.map(str::to_owned),
old_mode: None,
new_mode: None,
old_oid: None,
new_oid: None,
similarity: None,
is_binary: false,
is_submodule: false,
is_mode_only: false,
synthetic: false,
metadata_rows: Vec::new(),
hunks: vec![ReviewHunk {
id: HunkId::new("hunk:1"),
header: "@@ -1,4 +1,4 @@".to_owned(),
old_start: 1,
old_lines: 4,
new_start: 1,
new_lines: 4,
rows,
}],
}
}
fn ctx(text: &str) -> DiffRow {
row(DiffRowKind::Context, text)
}
fn removed(text: &str) -> DiffRow {
row(DiffRowKind::Removed, text)
}
fn added(text: &str) -> DiffRow {
row(DiffRowKind::Added, text)
}
fn binary_file() -> DiffFile {
let mut f = file_with(Some("a.rs"), Vec::new());
f.is_binary = true;
f
}
fn covers(line: &str, spans: &[EmphSpan], needle: &str) -> bool {
let Some(idx) = line.find(needle) else {
return false;
};
let (ns, ne) = (idx, idx + needle.len());
spans.iter().any(|s| s.start <= ns && ne <= s.end)
}
#[test]
fn emphspan_is_copy_two_field() {
let s = EmphSpan { start: 0, end: 3 };
let t = s; assert_eq!((s.start, s.end), (0, 3));
assert_eq!((t.start, t.end), (0, 3));
}
#[test]
fn emphasis_keys_match_highlight_rowkeys() {
let file = file_with(
Some("m.rs"),
vec![
ctx("x"),
removed("let b = 2;"),
added("let b = 3;"),
ctx("y"),
],
);
let e = emphasis_file(&file);
assert!(
covers("let b = 2;", &e[&(0, 1)], "2"),
"removed row (0,1) emphasizes the changed digit"
);
assert!(
covers("let b = 3;", &e[&(0, 2)], "3"),
"added row (0,2) emphasizes the changed digit"
);
assert!(
!e.contains_key(&(0, 0)) && !e.contains_key(&(0, 3)),
"context rows unmarked"
);
}
#[test]
fn emphasis_is_syntax_independent() {
let file = file_with(
Some("notes.txt"),
vec![removed("the quick brown fox"), added("the quick red fox")],
);
let e = emphasis_file(&file);
assert!(covers("the quick brown fox", &e[&(0, 0)], "brown"));
assert!(covers("the quick red fox", &e[&(0, 1)], "red"));
}
#[test]
fn emphasis_pairs_within_block_positionally_when_similar() {
let file = file_with(
Some("m.rs"),
vec![
removed("let a = 1;"),
removed("let b = 2;"),
added("let a = 9;"),
added("let b = 8;"),
],
);
let e = emphasis_file(&file);
assert!(covers("let a = 1;", &e[&(0, 0)], "1") && covers("let a = 9;", &e[&(0, 2)], "9"));
assert!(covers("let b = 2;", &e[&(0, 1)], "2") && covers("let b = 8;", &e[&(0, 3)], "8"));
}
#[test]
fn wholesale_replacement_in_block_is_not_emphasized() {
let file = file_with(
Some("m.rs"),
vec![
removed(" return Plain;"),
added(" throw new Error(\"unreachable\");"),
],
);
assert!(emphasis_file(&file).is_empty());
}
#[test]
fn binary_file_has_no_emphasis() {
let file = binary_file(); assert!(emphasis_file(&file).is_empty());
}
#[test]
fn tokenize_words_and_separators() {
assert_eq!(
tokenize("a.b cd"),
vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 6)]
);
}
#[test]
fn tokenize_empty_is_empty() {
assert_eq!(tokenize(""), Vec::<(usize, usize)>::new());
}
#[test]
fn tokenize_concatenation_reconstructs_line() {
let line = "let x = f(a, b);";
let joined: String = tokenize(line).iter().map(|&(s, e)| &line[s..e]).collect();
assert_eq!(joined, line);
}
#[test]
fn tokenize_multibyte_word_run_is_one_token() {
let line = "café x";
assert_eq!(tokenize(line), vec![(0, 5), (5, 6), (6, 7)]);
let joined: String = tokenize(line).iter().map(|&(s, e)| &line[s..e]).collect();
assert_eq!(joined, line);
}
#[test]
fn display_width_is_unicode_display_width() {
assert_eq!(display_width("café"), 4); assert_eq!(display_width("한"), 2); assert_eq!(display_width(""), 0);
}
#[test]
fn diff_pair_single_word_substitution_emphasizes_only_that_word() {
let (del, ins, dist) = diff_pair("let x = 1;", "let y = 1;");
assert!(dist < 0.6, "similar lines are homologous, dist={dist}");
assert_eq!(del, vec![EmphSpan { start: 4, end: 5 }]); assert_eq!(ins, vec![EmphSpan { start: 4, end: 5 }]); }
#[test]
fn diff_pair_wholesale_replacement_exceeds_guard() {
let (_, _, dist) = diff_pair("alpha beta", "gamma delta");
assert!(
dist > 0.6,
"fully-different words are a wholesale replacement, dist={dist}"
);
}
#[test]
fn diff_pair_trailing_whitespace_is_distance_zero_and_no_span() {
let (del, ins, dist) = diff_pair("let x", "let x ");
assert_eq!(dist, 0.0);
assert!(del.is_empty());
assert!(ins.is_empty(), "whitespace-only insertion is suppressed");
}
}