const DIFF_HASHBASE: u32 = 107_927;
#[derive(Debug, Clone)]
pub(crate) struct RenameLineTable {
byte_len: usize,
spans: Vec<(u32, u32)>,
}
impl RenameLineTable {
#[must_use]
pub(crate) fn from_blob(data: &[u8]) -> Self {
let is_text = is_text_for_rename(data);
Self {
byte_len: data.len(),
spans: span_weights(data, is_text),
}
}
#[must_use]
pub(crate) fn similarity_score(a: &Self, b: &Self) -> u32 {
let max_size = a.byte_len.max(b.byte_len);
if max_size == 0 {
return 100;
}
let copied = count_copied_span_bytes(a, b);
((copied * 100) / max_size).min(100) as u32
}
}
fn is_text_for_rename(data: &[u8]) -> bool {
!data.iter().take(8000).any(|&b| b == 0)
}
fn span_weights(buf: &[u8], is_text: bool) -> Vec<(u32, u32)> {
let mut out: Vec<(u32, u32)> = Vec::new();
let mut n = 0u32;
let mut accum1: u32 = 0;
let mut accum2: u32 = 0;
let mut i = 0usize;
while i < buf.len() {
let c = buf[i] as u32;
let old_1 = accum1;
i += 1;
if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
continue;
}
accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
accum1 = accum1.wrapping_add(c);
n += 1;
if n < 64 && c != b'\n' as u32 {
continue;
}
let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
out.push((hashval, n));
n = 0;
accum1 = 0;
accum2 = 0;
}
out.sort_unstable_by_key(|&(hash, _)| hash);
out.dedup_by(|later, first| {
if later.0 != first.0 {
return false;
}
first.1 = first.1.saturating_add(later.1);
true
});
out
}
fn count_copied_span_bytes(src: &RenameLineTable, dst: &RenameLineTable) -> usize {
let mut copied = 0usize;
let mut si = 0usize;
let mut di = 0usize;
let src_spans = &src.spans;
let dst_spans = &dst.spans;
while si < src_spans.len() {
let (s_hash, s_cnt) = src_spans[si];
while di < dst_spans.len() && dst_spans[di].0 < s_hash {
di += 1;
}
let dst_cnt = if di < dst_spans.len() && dst_spans[di].0 == s_hash {
dst_spans[di].1
} else {
0
};
copied += s_cnt.min(dst_cnt) as usize;
si += 1;
}
copied
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trailing_partial_span_is_ignored_for_similarity() {
let a = RenameLineTable::from_blob(b"hello");
let b = RenameLineTable::from_blob(b"hello");
assert_eq!(RenameLineTable::similarity_score(&a, &b), 0);
}
#[test]
fn similarity_scores_are_symmetric() {
let pairs: &[(&[u8], &[u8])] = &[
(b"".as_slice(), b"".as_slice()),
(b"hello\nworld\n", b"hello\nworld\n"),
(b"short\n", b"much longer\nunchanged tail\n"),
];
for (old, new) in pairs {
let a = RenameLineTable::from_blob(old);
let b = RenameLineTable::from_blob(new);
assert_eq!(
RenameLineTable::similarity_score(&a, &b),
RenameLineTable::similarity_score(&b, &a),
"old={old:?} new={new:?}"
);
}
}
#[test]
fn long_line_edit_after_64_bytes_can_still_match_spans() {
let prefix: Vec<u8> = (0..70).map(|_| b'x').collect();
let mut old = prefix.clone();
old.push(b'\n');
let mut new = prefix;
new[65] = b'y';
new.push(b'\n');
let score = RenameLineTable::similarity_score(
&RenameLineTable::from_blob(&old),
&RenameLineTable::from_blob(&new),
);
assert!(score >= 90, "score={score}");
}
}