Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion gix-blame/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ pub enum Error {
#[error(transparent)]
DiffTree(#[from] gix_diff::tree::Error),
#[error("Invalid line range was given, line range is expected to be a 1-based inclusive range in the format '<start>,<end>'")]
InvalidLineRange,
InvalidOneBasedLineRange,
#[error("Invalid line range was given, line range is expected to be a 0-based inclusive range in the format '<start>,<end>'")]
InvalidZeroBasedLineRange,
#[error("Failure to decode commit during traversal")]
DecodeCommit(#[from] gix_object::decode::Error),
#[error("Failed to get parent from commitgraph during traversal")]
Expand Down
14 changes: 5 additions & 9 deletions gix-blame/src/file/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,11 @@ pub fn file(
return Ok(Outcome::default());
}

let ranges = options.range.to_zero_based_exclusive(num_lines_in_blamed)?;
let mut hunks_to_blame = Vec::with_capacity(ranges.len());

for range in ranges {
hunks_to_blame.push(UnblamedHunk {
range_in_blamed_file: range.clone(),
suspects: [(suspect, range)].into(),
});
}
let ranges_to_blame = options.ranges.to_ranges(num_lines_in_blamed);
let mut hunks_to_blame = ranges_to_blame
.into_iter()
.map(|range| UnblamedHunk::new(range, suspect))
.collect::<Vec<_>>();

let (mut buf, mut buf2) = (Vec::new(), Vec::new());
let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf)?;
Expand Down
97 changes: 97 additions & 0 deletions gix-blame/src/file/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1338,3 +1338,100 @@ mod process_changes {
);
}
}

mod blame_ranges {
use crate::BlameRanges;

#[test]
fn create_from_single_range() {
let range = BlameRanges::from_one_based_inclusive_range(20..=40);
match range {
BlameRanges::PartialFile(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0], 19..40);
}
_ => panic!("Expected PartialFile variant"),
}
}

#[test]
fn create_from_multiple_ranges() {
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![1..=4, 10..=14]);
match ranges {
BlameRanges::PartialFile(ranges) => {
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0], 0..4);
assert_eq!(ranges[1], 9..14);
}
_ => panic!("Expected PartialFile variant"),
}
}

#[test]
fn add_range_merges_overlapping() {
let mut ranges = BlameRanges::from_one_based_inclusive_range(1..=5);
ranges.add_range(3..=7).unwrap();

match ranges {
BlameRanges::PartialFile(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0], 0..7);
}
_ => panic!("Expected PartialFile variant"),
}
}

#[test]
fn add_range_merges_adjacent() {
let mut ranges = BlameRanges::from_one_based_inclusive_range(1..=5);
ranges.add_range(6..=10).unwrap();

match ranges {
BlameRanges::PartialFile(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0], 0..10);
}
_ => panic!("Expected PartialFile variant"),
}
}

#[test]
fn add_range_keeps_separate() {
let mut ranges = BlameRanges::from_one_based_inclusive_range(1..=5);
ranges.add_range(6..=10).unwrap();

match ranges {
BlameRanges::PartialFile(ranges) => {
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0], 0..10);
}
_ => panic!("Expected PartialFile variant"),
}
}

#[test]
fn convert_to_zero_based_exclusive() {
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![1..=5, 10..=15]);
let ranges = ranges.to_ranges(20);
assert_eq!(ranges.len(), 2);
assert_eq!(ranges[0], 0..5);
assert_eq!(ranges[1], 9..15);
}

#[test]
fn convert_full_file_to_zero_based() {
let ranges = BlameRanges::WholeFile;
let ranges = ranges.to_ranges(100);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0], 0..100);
}

#[test]
fn default_is_full_file() {
let ranges = BlameRanges::default();
match ranges {
BlameRanges::WholeFile => (),
_ => panic!("Expected WholeFile variant"),
}
}
}
166 changes: 94 additions & 72 deletions gix-blame/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ use crate::Error;
/// use gix_blame::BlameRanges;
///
/// // Blame lines 20 through 40 (inclusive)
/// let range = BlameRanges::from_range(20..=40);
/// let range = BlameRanges::from_one_based_inclusive_range(20..=40);
///
/// // Blame multiple ranges
/// let mut ranges = BlameRanges::new();
/// ranges.add_range(1..=4); // Lines 1-4
/// ranges.add_range(10..=14); // Lines 10-14
/// let mut ranges = BlameRanges::from_one_based_inclusive_ranges(vec![
/// 1..=4, // Lines 1-4
/// 10..=14, // Lines 10-14
/// ]
/// );
/// ```
///
/// # Line Number Representation
Expand All @@ -36,43 +38,61 @@ use crate::Error;
/// - This will be converted to `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end
///
/// # Empty Ranges
///
/// An empty `BlameRanges` (created via `BlameRanges::new()` or `BlameRanges::default()`) means
/// to blame the entire file, similar to running `git blame` without line number arguments.
/// 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 struct BlameRanges {
/// The ranges to blame, stored as 1-based inclusive ranges
/// An empty Vec means blame the entire file
ranges: Vec<RangeInclusive<u32>>,
pub enum BlameRanges {
/// Blame the entire file.
#[default]
WholeFile,
/// Blame ranges in 0-based exclusive format.
PartialFile(Vec<Range<u32>>),
}

/// Lifecycle
impl BlameRanges {
/// Create a new empty BlameRanges instance.
///
/// An empty instance means to blame the entire file.
pub fn new() -> Self {
Self::default()
}

/// Create from a single range.
///
/// The range is 1-based, similar to git's line number format.
pub fn from_range(range: RangeInclusive<u32>) -> Self {
Self { ranges: vec![range] }
/// Note that the input range is 1-based inclusive, as used by git, and
/// the output is zero-based `BlameRanges` instance.
///
/// @param range: A 1-based inclusive range.
/// @return: A `BlameRanges` instance representing the range.
pub fn from_one_based_inclusive_range(range: RangeInclusive<u32>) -> Self {
let zero_based_range = Self::inclusive_to_zero_based_exclusive(range);
Self::PartialFile(vec![zero_based_range])
}

/// Create from multiple ranges.
///
/// All ranges are 1-based.
/// Overlapping or adjacent ranges will be merged.
pub fn from_ranges(ranges: Vec<RangeInclusive<u32>>) -> Self {
let mut result = Self::new();
for range in ranges {
result.merge_range(range);
/// Note that the input ranges are 1-based inclusive, as used by git, and
/// the output is zero-based `BlameRanges` instance.
///
/// If the input vector is empty, the result will be `WholeFile`.
///
/// @param ranges: A vec of 1-based inclusive range.
/// @return: A `BlameRanges` instance representing the range.
pub fn from_one_based_inclusive_ranges(ranges: Vec<RangeInclusive<u32>>) -> Self {
if ranges.is_empty() {
return Self::WholeFile;
}

let zero_based_ranges = ranges
.into_iter()
.map(Self::inclusive_to_zero_based_exclusive)
.collect::<Vec<_>>();
let mut result = Self::PartialFile(vec![]);
for range in zero_based_ranges {
let _ = result.merge_range(range);
}
result
}

/// Convert a 1-based inclusive range to a 0-based exclusive range.
fn inclusive_to_zero_based_exclusive(range: RangeInclusive<u32>) -> Range<u32> {
let start = range.start() - 1;
let end = *range.end();
start..end
}
}

impl BlameRanges {
Expand All @@ -81,60 +101,51 @@ impl BlameRanges {
/// The range should be 1-based inclusive.
/// If the new range overlaps with or is adjacent to an existing range,
/// they will be merged into a single range.
pub fn add_range(&mut self, new_range: RangeInclusive<u32>) {
self.merge_range(new_range);
pub fn add_range(&mut self, new_range: RangeInclusive<u32>) -> Result<(), Error> {
match self {
Self::PartialFile(_) => {
let zero_based_range = Self::inclusive_to_zero_based_exclusive(new_range);
self.merge_range(zero_based_range)
}
_ => Err(Error::InvalidOneBasedLineRange),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of erroring here, I think I prefer turning self into PartialFile and adding new_range. I assume (and hope) that that is what most people would expect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with which range would you then want to turn the self into a PartialFile? Initially the user was blaming the WholeFile and I doubt it then makes sense to then add an extra range.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea was to use new_range as the only range for PartialFile. My thinking was that turning self into PartialFile would make the API more ergonomic and easy to use as a user would not have to know anything about the internal state of BlameRanges to be able to call add_one_based_inclusive_range. In my view, this would mirror how git blame vs. git blame -L 1,5 -L 7,9 works. But I also think that it’s acceptable to be more explicit even if that makes the API harder to use.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(What I have in mind, seems similar to the logic in from_one_based_inclusive_ranges.)

}
}

/// Attempts to merge the new range with any existing ranges.
/// If no merge is possible, add it as a new range.
fn merge_range(&mut self, new_range: RangeInclusive<u32>) {
// Check if this range can be merged with any existing range
for range in &mut self.ranges {
// Check if ranges overlap or are adjacent
if new_range.start() <= range.end() && range.start() <= new_range.end() {
*range = *range.start().min(new_range.start())..=*range.end().max(new_range.end());
return;
fn merge_range(&mut self, new_range: Range<u32>) -> Result<(), Error> {
match self {
Self::PartialFile(ref mut ranges) => {
// Check if this range can be merged with any existing range
for range in &mut *ranges {
// Check if ranges overlap
if new_range.start <= range.end && range.start <= new_range.end {
*range = range.start.min(new_range.start)..range.end.max(new_range.end);
return Ok(());
}
// Check if ranges are adjacent
if new_range.start == range.end || range.start == new_range.end {
*range = range.start.min(new_range.start)..range.end.max(new_range.end);
return Ok(());
}
}
// If no overlap or adjacency found, add it as a new range
ranges.push(new_range);
Ok(())
}
_ => Err(Error::InvalidOneBasedLineRange),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: what about turning self into PartialFile?

}
// If no overlap found, add it as a new range
self.ranges.push(new_range);
}

/// Convert the 1-based inclusive ranges to 0-based exclusive ranges.
///
/// This is used internally by the blame algorithm to convert from git's line number format
/// to the internal format used for processing.
///
/// # Errors
///
/// Returns `Error::InvalidLineRange` if:
/// - Any range starts at 0 (must be 1-based)
/// - Any range extends beyond the file's length
/// - Any range has the same start and end
pub fn to_zero_based_exclusive(&self, max_lines: u32) -> Result<Vec<Range<u32>>, Error> {
if self.ranges.is_empty() {
let range = 0..max_lines;
return Ok(vec![range]);
}

let mut result = Vec::with_capacity(self.ranges.len());
for range in &self.ranges {
if *range.start() == 0 {
return Err(Error::InvalidLineRange);
}
let start = range.start() - 1;
let end = *range.end();
if start >= max_lines || end > max_lines || start == end {
return Err(Error::InvalidLineRange);
/// Convert the ranges to a vector of `Range<u32>`.
pub fn to_ranges(&self, max_lines: u32) -> Vec<Range<u32>> {
match self {
Self::WholeFile => {
let full_range = 0..max_lines;
vec![full_range]
}
result.push(start..end);
Self::PartialFile(ranges) => ranges.clone(),
}
Ok(result)
}

/// Returns true if no specific ranges are set (meaning blame entire file)
pub fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
}

Expand All @@ -144,7 +155,7 @@ pub struct Options {
/// The algorithm to use for diffing.
pub diff_algorithm: gix_diff::blob::Algorithm,
/// The ranges to blame in the file.
pub range: BlameRanges,
pub ranges: BlameRanges,
/// Don't consider commits before the given date.
pub since: Option<gix_date::Time>,
}
Expand Down Expand Up @@ -334,6 +345,17 @@ pub struct UnblamedHunk {
}

impl UnblamedHunk {
/// Create a new instance
pub fn new(range: Range<u32>, suspect: ObjectId) -> Self {
let range_start = range.start;
let range_end = range.end;

UnblamedHunk {
range_in_blamed_file: range_start..range_end,
suspects: [(suspect, range_start..range_end)].into(),
}
}

pub(crate) fn has_suspect(&self, suspect: &ObjectId) -> bool {
self.suspects.iter().any(|entry| entry.0 == *suspect)
}
Expand Down
Loading
Loading