use gix_hash::ObjectId; use gix_object::bstr::BString; use smallvec::SmallVec; use std::ops::RangeInclusive; use std::{ num::NonZeroU32, ops::{AddAssign, Range, SubAssign}, }; use crate::Error; use crate::file::function::tokens_for_diffing; /// A type to represent one or more line ranges to blame in a file. /// /// It handles the conversion between git's 1-based inclusive ranges and the internal /// 0-based exclusive ranges used by the blame algorithm. /// /// # Examples /// /// ```rust /// use gix_blame::BlameRanges; /// /// // Blame lines 20 through 40 (inclusive) /// let range = BlameRanges::from_one_based_inclusive_range(20..=40); /// /// // Blame multiple ranges /// let mut ranges = BlameRanges::from_one_based_inclusive_ranges(vec![ /// 1..=4, // Lines 1-4 /// 10..=14, // Lines 10-14 /// ] /// ); /// ``` /// /// # Line Number Representation /// /// This type uses 1-based inclusive ranges to mirror `git`'s behaviour: /// - A range of `20..=40` represents 21 lines, spanning from line 20 up to and including line 40 /// - This will be converted to `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end /// /// # Empty Ranges /// You can blame the entire file by calling `BlameRanges::default()`, or by passing an empty vector to `from_one_based_inclusive_ranges`. #[derive(Debug, Clone, Default)] pub enum BlameRanges { /// Blame the entire file. #[default] WholeFile, /// Blame ranges in 0-based exclusive format. PartialFile(Vec>), } /// Lifecycle impl BlameRanges { /// Create from a single 0-based range. /// /// Note that the input range is 1-based inclusive, as used by git, and /// the output is a zero-based `BlameRanges` instance. pub fn from_one_based_inclusive_range(range: RangeInclusive) -> Result { let zero_based_range = Self::inclusive_to_zero_based_exclusive(range)?; Ok(Self::PartialFile(vec![zero_based_range])) } /// Create from multiple 0-based ranges. /// /// Note that the input ranges are 1-based inclusive, as used by git, and /// the output is a zero-based `BlameRanges` instance. /// /// If the input vector is empty, the result will be `WholeFile`. pub fn from_one_based_inclusive_ranges(ranges: Vec>) -> Result { if ranges.is_empty() { return Ok(Self::WholeFile); } let zero_based_ranges = ranges .into_iter() .map(Self::inclusive_to_zero_based_exclusive) .collect::>(); let mut result = Self::PartialFile(vec![]); for range in zero_based_ranges { result.merge_zero_based_exclusive_range(range?); } Ok(result) } /// Convert a 1-based inclusive range to a 0-based exclusive range. fn inclusive_to_zero_based_exclusive(range: RangeInclusive) -> Result, Error> { if range.start() == &0 { return Err(Error::InvalidOneBasedLineRange); } let start = range.start() - 1; let end = *range.end(); Ok(start..end) } } impl BlameRanges { /// Add a single range to blame. /// /// The new range will be merged with any overlapping existing ranges. pub fn add_one_based_inclusive_range(&mut self, new_range: RangeInclusive) -> Result<(), Error> { let zero_based_range = Self::inclusive_to_zero_based_exclusive(new_range)?; self.merge_zero_based_exclusive_range(zero_based_range); Ok(()) } /// Adds a new ranges, merging it with any existing overlapping ranges. fn merge_zero_based_exclusive_range(&mut self, new_range: Range) { match self { Self::PartialFile(ranges) => { // Partition ranges into those that don't overlap and those that do. let (mut non_overlapping, overlapping): (Vec<_>, Vec<_>) = ranges .drain(..) .partition(|range| new_range.end < range.start || range.end < new_range.start); let merged_range = overlapping.into_iter().fold(new_range, |acc, range| { acc.start.min(range.start)..acc.end.max(range.end) }); non_overlapping.push(merged_range); *ranges = non_overlapping; ranges.sort_by_key(|a| a.start); } Self::WholeFile => *self = Self::PartialFile(vec![new_range]), } } /// Gets zero-based exclusive ranges. pub fn to_zero_based_exclusive_ranges(&self, max_lines: u32) -> Vec> { match self { Self::WholeFile => { let full_range = 0..max_lines; vec![full_range] } Self::PartialFile(ranges) => ranges .iter() .filter_map(|range| { if range.end < max_lines { return Some(range.clone()); } if range.start < max_lines { Some(range.start..max_lines) } else { None } }) .collect(), } } } /// Options to be passed to [`file()`](crate::file()). #[derive(Default, Debug, Clone)] pub struct Options { /// The algorithm to use for diffing. pub diff_algorithm: gix_diff::blob::Algorithm, /// The ranges to blame in the file. pub ranges: BlameRanges, /// Don't consider commits before the given date. pub since: Option, /// Determine if rename tracking should be performed, and how. pub rewrites: Option, /// Collect debug information whenever there's a diff or rename that affects the outcome of a /// blame. pub debug_track_path: bool, } /// Represents a change during history traversal for blame. It is supposed to capture enough /// information to allow reconstruction of the way a blame was performed, i. e. the path the /// history traversal, combined with repeated diffing of two subsequent states in this history, has /// taken. /// /// This is intended for debugging purposes. #[derive(Clone, Debug)] pub struct BlamePathEntry { /// The path to the *Source File* in the blob after the change. pub source_file_path: BString, /// The path to the *Source File* in the blob before the change. Allows /// detection of renames. `None` for root commits. pub previous_source_file_path: Option, /// The commit id associated with the state after the change. pub commit_id: ObjectId, /// The blob id associated with the state after the change. pub blob_id: ObjectId, /// The blob id associated with the state before the change. pub previous_blob_id: ObjectId, /// When there is more than one `BlamePathEntry` for a commit, this indicates to which parent /// commit the change is related. pub parent_index: usize, } /// The outcome of [`file()`](crate::file()). #[derive(Debug, Default, Clone)] pub struct Outcome { /// One entry in sequential order, to associate a hunk in the blamed file with the source commit (and its lines) /// that introduced it. pub entries: Vec, /// A buffer with the file content of the *Blamed File*, ready for tokenization. pub blob: Vec, /// Additional information about the amount of work performed to produce the blame. pub statistics: Statistics, /// Contains a log of all changes that affected the outcome of this blame. pub blame_path: Option>, } /// Additional information about the performed operations. #[derive(Debug, Default, Copy, Clone)] pub struct Statistics { /// The amount of commits it traversed until the blame was complete. pub commits_traversed: usize, /// The amount of trees that were decoded to find the entry of the file to blame. pub trees_decoded: usize, /// The amount of tree-diffs to see if the filepath was added, deleted or modified. These diffs /// are likely partial as they are cancelled as soon as a change to the blamed file is /// detected. pub trees_diffed: usize, /// The amount of tree-diffs to see if the file was moved (or rewritten, in git terminology). /// These diffs are likely partial as they are cancelled as soon as a change to the blamed file /// is detected. pub trees_diffed_with_rewrites: usize, /// The amount of blobs there were compared to each other to learn what changed between commits. /// Note that in order to diff a blob, one needs to load both versions from the database. pub blobs_diffed: usize, } impl Outcome { /// Return an iterator over each entry in [`Self::entries`], along with its lines, line by line. /// /// Note that [`Self::blob`] must be tokenized in exactly the same way as the tokenizer that was used /// to perform the diffs, which is what this method assures. pub fn entries_with_lines(&self) -> impl Iterator)> + '_ { use gix_diff::blob::TokenSource; let mut interner = gix_diff::blob::Interner::new(self.blob.len() / 100); let lines_as_tokens: Vec<_> = tokens_for_diffing(&self.blob) .tokenize() .map(|token| interner.intern(token)) .collect(); self.entries.iter().map(move |e| { ( e.clone(), lines_as_tokens[e.range_in_blamed_file()] .iter() .map(|token| BString::new(interner[*token].into())) .collect(), ) }) } } /// Describes the offset of a particular hunk relative to the *Blamed File*. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Offset { /// The amount of lines to add. Added(u32), /// The amount of lines to remove. Deleted(u32), } impl Offset { /// Shift the given `range` according to our offset. pub fn shifted_range(&self, range: &Range) -> Range { match self { Offset::Added(added) => { debug_assert!(range.start >= *added, "{self:?} {range:?}"); Range { start: range.start - added, end: range.end - added, } } Offset::Deleted(deleted) => Range { start: range.start + deleted, end: range.end + deleted, }, } } } impl AddAssign for Offset { fn add_assign(&mut self, rhs: u32) { match self { Self::Added(added) => *self = Self::Added(*added + rhs), Self::Deleted(deleted) => { if rhs > *deleted { *self = Self::Added(rhs - *deleted); } else { *self = Self::Deleted(*deleted - rhs); } } } } } impl SubAssign for Offset { fn sub_assign(&mut self, rhs: u32) { match self { Self::Added(added) => { if rhs > *added { *self = Self::Deleted(rhs - *added); } else { *self = Self::Added(*added - rhs); } } Self::Deleted(deleted) => *self = Self::Deleted(*deleted + rhs), } } } /// A mapping of a section of the *Blamed File* to the section in a *Source File* that introduced it. /// /// Both ranges are of the same size, but may use different [starting points](Range::start). Naturally, /// they have the same content, which is the reason they are in what is returned by [`file()`](crate::file()). #[derive(Clone, Debug, PartialEq)] pub struct BlameEntry { /// The index of the token in the *Blamed File* (typically lines) where this entry begins. pub start_in_blamed_file: u32, /// The index of the token in the *Source File* (typically lines) where this entry begins. /// /// This is possibly offset compared to `start_in_blamed_file`. pub start_in_source_file: u32, /// The amount of lines the hunk is spanning. pub len: NonZeroU32, /// The commit that introduced the section into the *Source File*. pub commit_id: ObjectId, /// The *Source File*'s name, in case it differs from *Blamed File*'s name. /// This happens when the file was renamed. pub source_file_name: Option, } impl BlameEntry { /// Create a new instance. pub fn new( range_in_blamed_file: Range, range_in_source_file: Range, commit_id: ObjectId, source_file_name: Option, ) -> Self { debug_assert!( range_in_blamed_file.end > range_in_blamed_file.start, "{range_in_blamed_file:?}" ); debug_assert!( range_in_source_file.end > range_in_source_file.start, "{range_in_source_file:?}" ); debug_assert_eq!(range_in_source_file.len(), range_in_blamed_file.len()); Self { start_in_blamed_file: range_in_blamed_file.start, start_in_source_file: range_in_source_file.start, len: NonZeroU32::new(range_in_blamed_file.len() as u32).expect("BUG: hunks are never empty"), commit_id, source_file_name, } } } impl BlameEntry { /// Return the range of tokens this entry spans in the *Blamed File*. pub fn range_in_blamed_file(&self) -> Range { let start = self.start_in_blamed_file as usize; start..start + self.len.get() as usize } /// Return the range of tokens this entry spans in the *Source File*. pub fn range_in_source_file(&self) -> Range { let start = self.start_in_source_file as usize; start..start + self.len.get() as usize } } pub(crate) trait LineRange { fn shift_by(&self, offset: Offset) -> Self; } impl LineRange for Range { fn shift_by(&self, offset: Offset) -> Self { offset.shifted_range(self) } } /// Tracks the hunks in the *Blamed File* that are not yet associated with the commit that introduced them. #[derive(Debug, PartialEq)] pub struct UnblamedHunk { /// The range in the file that is being blamed that this hunk represents. pub range_in_blamed_file: Range, /// Maps a commit to the range in a source file (i.e. *Blamed File* at a revision) that is /// equal to `range_in_blamed_file`. Since `suspects` rarely contains more than 1 item, it can /// efficiently be stored as a `SmallVec`. pub suspects: SmallVec<[(ObjectId, Range); 1]>, /// The *Source File*'s name, in case it differs from *Blamed File*'s name. pub source_file_name: Option, } impl UnblamedHunk { pub(crate) fn new(from_range_in_blamed_file: Range, suspect: ObjectId) -> Self { let range_start = from_range_in_blamed_file.start; let range_end = from_range_in_blamed_file.end; UnblamedHunk { range_in_blamed_file: range_start..range_end, suspects: [(suspect, range_start..range_end)].into(), source_file_name: None, } } pub(crate) fn has_suspect(&self, suspect: &ObjectId) -> bool { self.suspects.iter().any(|entry| entry.0 == *suspect) } pub(crate) fn get_range(&self, suspect: &ObjectId) -> Option<&Range> { self.suspects .iter() .find(|entry| entry.0 == *suspect) .map(|entry| &entry.1) } } #[derive(Debug)] pub(crate) enum Either { Left(T), Right(U), } /// A single change between two blobs, or an unchanged region. /// /// Line numbers refer to the file that is referred to as `after` or `NewOrDestination`, depending /// on the context. #[derive(Clone, Debug, PartialEq)] pub enum Change { /// A range of tokens that wasn't changed. Unchanged(Range), /// `(added_line_range, num_deleted_in_before)` AddedOrReplaced(Range, u32), /// `(line_to_start_deletion_at, num_deleted_in_before)` Deleted(u32, u32), }