use std::collections::HashMap;
use std::ops::Range;
use super::{
Attribution, BlameError, BlameOptions, BlameOutcome, CopyDetection, MoveDetection,
blame_file_with, line_key, load_blob_lines,
};
use crate::hash::Hash;
use crate::object::{EntryMode, Object};
use crate::ops::diff::diff_trees;
use crate::ops::is_ancestor;
use crate::store::ObjectStore;
const MAX_DETECT_RUN: usize = 10_000;
pub(super) type KeyIndex = HashMap<Vec<u8>, Vec<usize>>;
pub(super) struct ReassignRequest<'r> {
pub file_path: &'r str,
pub source_commit: Hash,
pub attributed_commit: Hash,
pub new_lines: &'r [Vec<u8>],
pub within_file: Option<(&'r [Vec<u8>], &'r [Attribution])>,
}
pub(super) struct Detector<'a> {
store: &'a ObjectStore,
opts: &'a BlameOptions,
keys_cache: HashMap<Hash, (Vec<Vec<u8>>, KeyIndex)>,
attrs_cache: HashMap<(Hash, String), Vec<Attribution>>,
ancestry_cache: HashMap<(Hash, Hash), bool>,
}
enum BlockSource {
WithinFile { offset: usize },
Copy { path: String, offset: usize },
}
struct Ctx<'c> {
source_commit: Hash,
new_keys: Vec<Vec<u8>>,
alnum_prefix: Vec<usize>,
within_keys: Option<Vec<Vec<u8>>>,
within_index: Option<KeyIndex>,
within_attrs: Option<&'c [Attribution]>,
move_threshold: Option<usize>,
candidates: Vec<(String, Hash)>,
copy_threshold: Option<usize>,
}
impl Ctx<'_> {
fn alnum(&self, s: usize, e: usize) -> usize {
self.alnum_prefix[e] - self.alnum_prefix[s]
}
}
impl<'a> Detector<'a> {
pub(super) fn new(store: &'a ObjectStore, opts: &'a BlameOptions) -> Self {
Self {
store,
opts,
keys_cache: HashMap::new(),
attrs_cache: HashMap::new(),
ancestry_cache: HashMap::new(),
}
}
fn is_ancestor_cached(&mut self, ancestor: Hash, descendant: Hash) -> BlameOutcome<bool> {
if let Some(&v) = self.ancestry_cache.get(&(ancestor, descendant)) {
return Ok(v);
}
let v = is_ancestor(self.store, ancestor, descendant)?;
self.ancestry_cache.insert((ancestor, descendant), v);
Ok(v)
}
pub(super) fn reassign(
&mut self,
req: &ReassignRequest,
out: &mut [Attribution],
claimed: &mut [bool],
) -> BlameOutcome<()> {
let iw = self.opts.ignore_whitespace;
let move_threshold = match self.opts.effective_move() {
MoveDetection::On { threshold } => req.within_file.map(|_| threshold),
MoveDetection::Off => None,
};
let (copy_level, copy_threshold) = match self.opts.copies {
CopyDetection::On { level, threshold } => (level, Some(threshold)),
CopyDetection::Off => (0, None),
};
if move_threshold.is_none() && copy_threshold.is_none() {
return Ok(());
}
let mut stack: Vec<Range<usize>> = unclaimed_runs(claimed);
if stack.is_empty() {
return Ok(());
}
let candidates = if copy_level > 0 {
copy_candidates(
self.store,
req.source_commit,
req.attributed_commit,
req.file_path,
copy_level,
req.within_file.is_some(),
)?
} else {
Vec::new()
};
let (within_keys, within_index) = match req.within_file {
Some((lines, _)) => {
let keys: Vec<Vec<u8>> = lines.iter().map(|l| line_key(l, iw)).collect();
let index = build_index(&keys);
(Some(keys), Some(index))
}
None => (None, None),
};
let ctx = Ctx {
source_commit: req.source_commit,
new_keys: req.new_lines.iter().map(|l| line_key(l, iw)).collect(),
alnum_prefix: alnum_prefix(req.new_lines),
within_keys,
within_index,
within_attrs: req.within_file.map(|(_, attrs)| attrs),
move_threshold,
candidates,
copy_threshold,
};
while let Some(region) = stack.pop() {
if region.is_empty() {
continue;
}
let Some((s, e, source)) = self.find_best_block(®ion, &ctx)? else {
continue;
};
self.credit(s, e, &source, &ctx, out)?;
claimed[s..e].fill(true);
stack.push(region.start..s);
stack.push(e..region.end);
}
Ok(())
}
fn credit(
&mut self,
s: usize,
e: usize,
source: &BlockSource,
ctx: &Ctx,
out: &mut [Attribution],
) -> BlameOutcome<()> {
match source {
BlockSource::WithinFile { offset } => {
let attrs = ctx.within_attrs.expect("within-file source present");
copy_origins(out, s, e - s, attrs, *offset, None);
}
BlockSource::Copy { path, offset } => {
let attrs = self.candidate_attrs(ctx.source_commit, path)?;
copy_origins(out, s, e - s, attrs, *offset, Some(path.as_str()));
}
}
Ok(())
}
fn find_best_block(
&mut self,
region: &Range<usize>,
ctx: &Ctx,
) -> BlameOutcome<Option<(usize, usize, BlockSource)>> {
if region.len() > MAX_DETECT_RUN {
return Ok(self
.longest_block_at(region.start, region.end, ctx)?
.filter(|(len, _)| region.start + len == region.end)
.map(|(len, src)| (region.start, region.start + len, src)));
}
let mut best: Option<(usize, usize, BlockSource)> = None;
for s in region.start..region.end {
if let Some((len, source)) = self.longest_block_at(s, region.end, ctx)? {
let longer = best.as_ref().is_none_or(|(bs, be, _)| be - bs < len);
if longer {
best = Some((s, s + len, source));
}
}
}
Ok(best)
}
fn longest_block_at(
&mut self,
s: usize,
end: usize,
ctx: &Ctx,
) -> BlameOutcome<Option<(usize, BlockSource)>> {
let needle = &ctx.new_keys[s..end];
let mut best: Option<(usize, BlockSource)> = None;
let moved = match (ctx.move_threshold, &ctx.within_keys, &ctx.within_index) {
(Some(threshold), Some(keys), Some(index)) => longest_match(needle, keys, index)
.filter(|&(len, _)| ctx.alnum(s, s + len) >= threshold),
_ => None,
};
if let Some((len, offset)) = moved {
best = Some((len, BlockSource::WithinFile { offset }));
}
if let Some(threshold) = ctx.copy_threshold {
for ci in 0..ctx.candidates.len() {
let blob = ctx.candidates[ci].1;
self.ensure_candidate(blob)?;
let matched = {
let (keys, index) = &self.keys_cache[&blob];
longest_match(needle, keys, index)
};
let Some((len, offset)) = matched else {
continue;
};
if ctx.alnum(s, s + len) < threshold {
continue;
}
let take = match &best {
None => true,
Some((bl, _)) if len > *bl => true,
Some((bl, _)) if len < *bl => false,
Some((_, BlockSource::WithinFile { .. })) => false,
Some((
_,
BlockSource::Copy {
path: cur_path,
offset: cur_off,
},
)) => self.copy_source_is_older(
ctx.source_commit,
&ctx.candidates[ci].0,
offset,
cur_path,
*cur_off,
)?,
};
if take {
best = Some((
len,
BlockSource::Copy {
path: ctx.candidates[ci].0.clone(),
offset,
},
));
}
}
}
Ok(best)
}
fn copy_source_is_older(
&mut self,
source_commit: Hash,
new_path: &str,
new_off: usize,
cur_path: &str,
cur_off: usize,
) -> BlameOutcome<bool> {
let Some(new_origin) = self.copy_origin(source_commit, new_path, new_off)? else {
return Ok(false);
};
let Some(cur_origin) = self.copy_origin(source_commit, cur_path, cur_off)? else {
return Ok(false);
};
if new_origin.commit_hash == cur_origin.commit_hash {
return Ok(false);
}
if self.is_ancestor_cached(new_origin.commit_hash, cur_origin.commit_hash)? {
return Ok(true);
}
if self.is_ancestor_cached(cur_origin.commit_hash, new_origin.commit_hash)? {
return Ok(false);
}
Ok(new_origin.timestamp < cur_origin.timestamp)
}
fn copy_origin(
&mut self,
commit: Hash,
path: &str,
offset: usize,
) -> BlameOutcome<Option<Attribution>> {
let attrs = self.candidate_attrs(commit, path)?;
Ok(attrs.get(offset).cloned())
}
fn ensure_candidate(&mut self, blob: Hash) -> BlameOutcome<()> {
if !self.keys_cache.contains_key(&blob) {
let iw = self.opts.ignore_whitespace;
let lines = load_blob_lines(self.store, blob)?;
let keys: Vec<Vec<u8>> = lines.iter().map(|l| line_key(l, iw)).collect();
let index = build_index(&keys);
self.keys_cache.insert(blob, (keys, index));
}
Ok(())
}
fn candidate_attrs(&mut self, commit: Hash, path: &str) -> BlameOutcome<&[Attribution]> {
let key = (commit, path.to_string());
if !self.attrs_cache.contains_key(&key) {
let source_opts = BlameOptions {
moves: self.opts.effective_move(),
copies: CopyDetection::Off,
..self.opts.clone()
};
let res = blame_file_with(self.store, commit, path, &source_opts)?;
let attrs = res.lines.into_iter().map(Attribution::from).collect();
self.attrs_cache.insert(key.clone(), attrs);
}
Ok(&self.attrs_cache[&key])
}
}
fn copy_origins(
out: &mut [Attribution],
dst: usize,
len: usize,
src: &[Attribution],
src_off: usize,
source_path: Option<&str>,
) {
for k in 0..len {
if let Some(a) = src.get(src_off + k) {
let mut a = a.clone();
if let Some(path) = source_path {
a.source_path = Some(path.to_string());
}
out[dst + k] = a;
}
}
}
fn longest_match(needle: &[Vec<u8>], hay: &[Vec<u8>], index: &KeyIndex) -> Option<(usize, usize)> {
let first = needle.first()?;
let offsets = index.get(first)?;
let mut best: Option<(usize, usize)> = None;
for &oi in offsets {
let len = needle
.iter()
.zip(&hay[oi..])
.take_while(|(a, b)| a == b)
.count();
if best.is_none_or(|(bl, _)| len > bl) {
best = Some((len, oi));
}
}
best
}
pub(super) fn build_index(keys: &[Vec<u8>]) -> KeyIndex {
let mut index: KeyIndex = HashMap::with_capacity(keys.len());
for (i, k) in keys.iter().enumerate() {
index.entry(k.clone()).or_default().push(i);
}
index
}
fn alnum_prefix(lines: &[Vec<u8>]) -> Vec<usize> {
let mut prefix = Vec::with_capacity(lines.len() + 1);
let mut acc = 0;
prefix.push(0);
for l in lines {
acc += l.iter().filter(|b| b.is_ascii_alphanumeric()).count();
prefix.push(acc);
}
prefix
}
fn unclaimed_runs(claimed: &[bool]) -> Vec<Range<usize>> {
let mut runs = Vec::new();
let mut i = 0;
while i < claimed.len() {
if claimed[i] {
i += 1;
continue;
}
let start = i;
while i < claimed.len() && !claimed[i] {
i += 1;
}
runs.push(start..i);
}
runs
}
fn copy_candidates(
store: &ObjectStore,
older: Hash,
newer: Hash,
target_path: &str,
level: u8,
has_porigin: bool,
) -> BlameOutcome<Vec<(String, Hash)>> {
let older_tree = commit_tree(store, older)?;
let whole_tree = (level >= 2 && !has_porigin) || level >= 3;
let entries = if whole_tree {
diff_trees(store, Some(older_tree), None)?.entries
} else {
let newer_tree = commit_tree(store, newer)?;
diff_trees(store, Some(older_tree), Some(newer_tree))?.entries
};
Ok(entries
.into_iter()
.filter_map(|e| {
let hash = e.old_hash?;
if !whole_tree && e.new_hash == Some(hash) {
return None;
}
if !matches!(e.old_mode, Some(EntryMode::Blob | EntryMode::Executable)) {
return None;
}
if e.path.contains('\u{FFFD}') || (has_porigin && e.path == target_path) {
return None;
}
Some((e.path, hash))
})
.collect())
}
pub(super) fn commit_parents(store: &ObjectStore, commit: Hash) -> BlameOutcome<Vec<Hash>> {
let Object::Commit(c) = store.read_object(&commit)? else {
return Err(BlameError::NotACommit);
};
Ok(c.parents.clone())
}
fn commit_tree(store: &ObjectStore, commit: Hash) -> BlameOutcome<Hash> {
let Object::Commit(c) = store.read_object(&commit)? else {
return Err(BlameError::NotACommit);
};
Ok(c.tree_hash)
}