Skip to content

Commit 2230fde

Browse files
cruesslerholodorum
andcommitted
feat!: support multiple range formats in BlameRanges
This modification introduces changes to the `BlameRanges` struct, converting it into an enum to support both `PartialFile` and `WholeFile`. Internally the ranges in `BlameRanges` are stored as zero-based exclusive ranges now. Co-authored-by: Bart Dubbeldam <bartdubbeldam2000@gmail.com>
1 parent 268dac4 commit 2230fde

File tree

5 files changed

+203
-94
lines changed

5 files changed

+203
-94
lines changed

gix-blame/src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pub enum Error {
3030
#[error(transparent)]
3131
DiffTreeWithRewrites(#[from] gix_diff::tree_with_rewrites::Error),
3232
#[error("Invalid line range was given, line range is expected to be a 1-based inclusive range in the format '<start>,<end>'")]
33-
InvalidLineRange,
33+
InvalidOneBasedLineRange,
3434
#[error("Failure to decode commit during traversal")]
3535
DecodeCommit(#[from] gix_object::decode::Error),
3636
#[error("Failed to get parent from commitgraph during traversal")]

gix-blame/src/file/function.rs

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,11 @@ pub fn file(
9595
return Ok(Outcome::default());
9696
}
9797

98-
let ranges = options.range.to_zero_based_exclusive(num_lines_in_blamed)?;
99-
let mut hunks_to_blame = Vec::with_capacity(ranges.len());
100-
101-
for range in ranges {
102-
hunks_to_blame.push(UnblamedHunk {
103-
range_in_blamed_file: range.clone(),
104-
suspects: [(suspect, range)].into(),
105-
source_file_name: None,
106-
});
107-
}
98+
let ranges_to_blame = options.ranges.to_zero_based_exclusive_ranges(num_lines_in_blamed);
99+
let mut hunks_to_blame = ranges_to_blame
100+
.into_iter()
101+
.map(|range| UnblamedHunk::new(range, suspect))
102+
.collect::<Vec<_>>();
108103

109104
let (mut buf, mut buf2) = (Vec::new(), Vec::new());
110105
let commit = find_commit(cache.as_ref(), &odb, &suspect, &mut buf)?;

gix-blame/src/file/tests.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,3 +985,105 @@ mod process_changes {
985985
);
986986
}
987987
}
988+
989+
mod blame_ranges {
990+
use crate::{BlameRanges, Error};
991+
992+
#[test]
993+
fn create_with_invalid_range() {
994+
let ranges = BlameRanges::from_one_based_inclusive_range(0..=10);
995+
996+
assert!(matches!(ranges, Err(Error::InvalidOneBasedLineRange)));
997+
}
998+
999+
#[test]
1000+
fn create_from_single_range() {
1001+
let ranges = BlameRanges::from_one_based_inclusive_range(20..=40).unwrap();
1002+
1003+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![19..40]);
1004+
}
1005+
1006+
#[test]
1007+
fn create_from_multiple_ranges() {
1008+
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![1..=4, 10..=14]).unwrap();
1009+
1010+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..4, 9..14]);
1011+
}
1012+
1013+
#[test]
1014+
fn create_with_empty_ranges() {
1015+
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![]).unwrap();
1016+
1017+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..100]);
1018+
}
1019+
1020+
#[test]
1021+
fn add_range_merges_overlapping() {
1022+
let mut ranges = BlameRanges::from_one_based_inclusive_range(1..=5).unwrap();
1023+
ranges.add_one_based_inclusive_range(3..=7).unwrap();
1024+
1025+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..7]);
1026+
}
1027+
1028+
#[test]
1029+
fn add_range_merges_overlapping_both() {
1030+
let mut ranges = BlameRanges::from_one_based_inclusive_range(1..=3).unwrap();
1031+
ranges.add_one_based_inclusive_range(5..=7).unwrap();
1032+
ranges.add_one_based_inclusive_range(2..=6).unwrap();
1033+
1034+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..7]);
1035+
}
1036+
1037+
#[test]
1038+
fn add_range_non_sorted() {
1039+
let mut ranges = BlameRanges::from_one_based_inclusive_range(5..=7).unwrap();
1040+
ranges.add_one_based_inclusive_range(1..=3).unwrap();
1041+
1042+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..3, 4..7]);
1043+
}
1044+
1045+
#[test]
1046+
fn add_range_merges_adjacent() {
1047+
let mut ranges = BlameRanges::from_one_based_inclusive_range(1..=5).unwrap();
1048+
ranges.add_one_based_inclusive_range(6..=10).unwrap();
1049+
1050+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..10]);
1051+
}
1052+
1053+
#[test]
1054+
fn non_sorted_ranges() {
1055+
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![10..=15, 1..=5]).unwrap();
1056+
1057+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..5, 9..15]);
1058+
}
1059+
1060+
#[test]
1061+
fn convert_to_zero_based_exclusive() {
1062+
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![1..=5, 10..=15]).unwrap();
1063+
1064+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..5, 9..15]);
1065+
}
1066+
1067+
#[test]
1068+
fn convert_full_file_to_zero_based() {
1069+
let ranges = BlameRanges::WholeFile;
1070+
1071+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..100]);
1072+
}
1073+
1074+
#[test]
1075+
fn adding_a_range_turns_whole_file_into_partial_file() {
1076+
let mut ranges = BlameRanges::default();
1077+
1078+
ranges.add_one_based_inclusive_range(1..=10).unwrap();
1079+
1080+
assert_eq!(ranges.to_zero_based_exclusive_ranges(100), vec![0..10]);
1081+
}
1082+
1083+
#[test]
1084+
fn default_is_full_file() {
1085+
let ranges = BlameRanges::default();
1086+
1087+
assert!(matches!(ranges, BlameRanges::WholeFile));
1088+
}
1089+
}

gix-blame/src/types.rs

Lines changed: 87 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ use crate::Error;
2121
/// use gix_blame::BlameRanges;
2222
///
2323
/// // Blame lines 20 through 40 (inclusive)
24-
/// let range = BlameRanges::from_range(20..=40);
24+
/// let range = BlameRanges::from_one_based_inclusive_range(20..=40);
2525
///
2626
/// // Blame multiple ranges
27-
/// let mut ranges = BlameRanges::new();
28-
/// ranges.add_range(1..=4); // Lines 1-4
29-
/// ranges.add_range(10..=14); // Lines 10-14
27+
/// let mut ranges = BlameRanges::from_one_based_inclusive_ranges(vec![
28+
/// 1..=4, // Lines 1-4
29+
/// 10..=14, // Lines 10-14
30+
/// ]
31+
/// );
3032
/// ```
3133
///
3234
/// # Line Number Representation
@@ -36,105 +38,102 @@ use crate::Error;
3638
/// - This will be converted to `19..40` internally as the algorithm uses 0-based ranges that are exclusive at the end
3739
///
3840
/// # Empty Ranges
39-
///
40-
/// An empty `BlameRanges` (created via `BlameRanges::new()` or `BlameRanges::default()`) means
41-
/// to blame the entire file, similar to running `git blame` without line number arguments.
41+
/// You can blame the entire file by calling `BlameRanges::default()`, or by passing an empty vector to `from_one_based_inclusive_ranges`.
4242
#[derive(Debug, Clone, Default)]
43-
pub struct BlameRanges {
44-
/// The ranges to blame, stored as 1-based inclusive ranges
45-
/// An empty Vec means blame the entire file
46-
ranges: Vec<RangeInclusive<u32>>,
43+
pub enum BlameRanges {
44+
/// Blame the entire file.
45+
#[default]
46+
WholeFile,
47+
/// Blame ranges in 0-based exclusive format.
48+
PartialFile(Vec<Range<u32>>),
4749
}
4850

4951
/// Lifecycle
5052
impl BlameRanges {
51-
/// Create a new empty BlameRanges instance.
53+
/// Create from a single 0-based range.
5254
///
53-
/// An empty instance means to blame the entire file.
54-
pub fn new() -> Self {
55-
Self::default()
55+
/// Note that the input range is 1-based inclusive, as used by git, and
56+
/// the output is a zero-based `BlameRanges` instance.
57+
pub fn from_one_based_inclusive_range(range: RangeInclusive<u32>) -> Result<Self, Error> {
58+
let zero_based_range = Self::inclusive_to_zero_based_exclusive(range)?;
59+
Ok(Self::PartialFile(vec![zero_based_range]))
5660
}
5761

58-
/// Create from a single range.
62+
/// Create from multiple 0-based ranges.
63+
///
64+
/// Note that the input ranges are 1-based inclusive, as used by git, and
65+
/// the output is a zero-based `BlameRanges` instance.
5966
///
60-
/// The range is 1-based, similar to git's line number format.
61-
pub fn from_range(range: RangeInclusive<u32>) -> Self {
62-
Self { ranges: vec![range] }
67+
/// If the input vector is empty, the result will be `WholeFile`.
68+
pub fn from_one_based_inclusive_ranges(ranges: Vec<RangeInclusive<u32>>) -> Result<Self, Error> {
69+
if ranges.is_empty() {
70+
return Ok(Self::WholeFile);
71+
}
72+
73+
let zero_based_ranges = ranges
74+
.into_iter()
75+
.map(Self::inclusive_to_zero_based_exclusive)
76+
.collect::<Vec<_>>();
77+
let mut result = Self::PartialFile(vec![]);
78+
for range in zero_based_ranges {
79+
result.merge_zero_based_exclusive_range(range?);
80+
}
81+
Ok(result)
6382
}
6483

65-
/// Create from multiple ranges.
66-
///
67-
/// All ranges are 1-based.
68-
/// Overlapping or adjacent ranges will be merged.
69-
pub fn from_ranges(ranges: Vec<RangeInclusive<u32>>) -> Self {
70-
let mut result = Self::new();
71-
for range in ranges {
72-
result.merge_range(range);
84+
/// Convert a 1-based inclusive range to a 0-based exclusive range.
85+
fn inclusive_to_zero_based_exclusive(range: RangeInclusive<u32>) -> Result<Range<u32>, Error> {
86+
if range.start() == &0 {
87+
return Err(Error::InvalidOneBasedLineRange);
7388
}
74-
result
89+
let start = range.start() - 1;
90+
let end = *range.end();
91+
Ok(start..end)
7592
}
7693
}
7794

7895
impl BlameRanges {
7996
/// Add a single range to blame.
8097
///
81-
/// The range should be 1-based inclusive.
82-
/// If the new range overlaps with or is adjacent to an existing range,
83-
/// they will be merged into a single range.
84-
pub fn add_range(&mut self, new_range: RangeInclusive<u32>) {
85-
self.merge_range(new_range);
86-
}
98+
/// The new range will be merged with any overlapping existing ranges.
99+
pub fn add_one_based_inclusive_range(&mut self, new_range: RangeInclusive<u32>) -> Result<(), Error> {
100+
let zero_based_range = Self::inclusive_to_zero_based_exclusive(new_range)?;
101+
self.merge_zero_based_exclusive_range(zero_based_range);
87102

88-
/// Attempts to merge the new range with any existing ranges.
89-
/// If no merge is possible, add it as a new range.
90-
fn merge_range(&mut self, new_range: RangeInclusive<u32>) {
91-
// Check if this range can be merged with any existing range
92-
for range in &mut self.ranges {
93-
// Check if ranges overlap or are adjacent
94-
if new_range.start() <= range.end() && range.start() <= new_range.end() {
95-
*range = *range.start().min(new_range.start())..=*range.end().max(new_range.end());
96-
return;
97-
}
98-
}
99-
// If no overlap found, add it as a new range
100-
self.ranges.push(new_range);
103+
Ok(())
101104
}
102105

103-
/// Convert the 1-based inclusive ranges to 0-based exclusive ranges.
104-
///
105-
/// This is used internally by the blame algorithm to convert from git's line number format
106-
/// to the internal format used for processing.
107-
///
108-
/// # Errors
109-
///
110-
/// Returns `Error::InvalidLineRange` if:
111-
/// - Any range starts at 0 (must be 1-based)
112-
/// - Any range extends beyond the file's length
113-
/// - Any range has the same start and end
114-
pub fn to_zero_based_exclusive(&self, max_lines: u32) -> Result<Vec<Range<u32>>, Error> {
115-
if self.ranges.is_empty() {
116-
let range = 0..max_lines;
117-
return Ok(vec![range]);
118-
}
106+
/// Adds a new ranges, merging it with any existing overlapping ranges.
107+
fn merge_zero_based_exclusive_range(&mut self, new_range: Range<u32>) {
108+
match self {
109+
Self::PartialFile(ref mut ranges) => {
110+
// Partition ranges into those that don't overlap and those that do.
111+
let (mut non_overlapping, overlapping): (Vec<_>, Vec<_>) = ranges
112+
.drain(..)
113+
.partition(|range| new_range.end < range.start || range.end < new_range.start);
119114

120-
let mut result = Vec::with_capacity(self.ranges.len());
121-
for range in &self.ranges {
122-
if *range.start() == 0 {
123-
return Err(Error::InvalidLineRange);
124-
}
125-
let start = range.start() - 1;
126-
let end = *range.end();
127-
if start >= max_lines || end > max_lines || start == end {
128-
return Err(Error::InvalidLineRange);
115+
let merged_range = overlapping.into_iter().fold(new_range, |acc, range| {
116+
acc.start.min(range.start)..acc.end.max(range.end)
117+
});
118+
119+
non_overlapping.push(merged_range);
120+
121+
*ranges = non_overlapping;
122+
ranges.sort_by(|a, b| a.start.cmp(&b.start));
129123
}
130-
result.push(start..end);
124+
Self::WholeFile => *self = Self::PartialFile(vec![new_range]),
131125
}
132-
Ok(result)
133126
}
134127

135-
/// Returns true if no specific ranges are set (meaning blame entire file)
136-
pub fn is_empty(&self) -> bool {
137-
self.ranges.is_empty()
128+
/// Gets zero-based exclusive ranges.
129+
pub fn to_zero_based_exclusive_ranges(&self, max_lines: u32) -> Vec<Range<u32>> {
130+
match self {
131+
Self::WholeFile => {
132+
let full_range = 0..max_lines;
133+
vec![full_range]
134+
}
135+
Self::PartialFile(ranges) => ranges.clone(),
136+
}
138137
}
139138
}
140139

@@ -380,6 +379,17 @@ pub struct UnblamedHunk {
380379
}
381380

382381
impl UnblamedHunk {
382+
pub(crate) fn new(from_range_in_blamed_file: Range<u32>, suspect: ObjectId) -> Self {
383+
let range_start = from_range_in_blamed_file.start;
384+
let range_end = from_range_in_blamed_file.end;
385+
386+
UnblamedHunk {
387+
range_in_blamed_file: range_start..range_end,
388+
suspects: [(suspect, range_start..range_end)].into(),
389+
source_file_name: None,
390+
}
391+
}
392+
383393
pub(crate) fn has_suspect(&self, suspect: &ObjectId) -> bool {
384394
self.suspects.iter().any(|entry| entry.0 == *suspect)
385395
}

gix-blame/tests/blame.rs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ mod blame_ranges {
422422
source_file_name.as_ref(),
423423
gix_blame::Options {
424424
diff_algorithm: gix_diff::blob::Algorithm::Histogram,
425-
ranges: BlameRanges::from_range(1..=2),
425+
ranges: BlameRanges::from_one_based_inclusive_range(1..=2).unwrap(),
426426
since: None,
427427
rewrites: Some(gix_diff::Rewrites::default()),
428428
debug_track_path: false,
@@ -448,10 +448,12 @@ mod blame_ranges {
448448
suspect,
449449
} = Fixture::new()?;
450450

451-
let mut ranges = BlameRanges::new();
452-
ranges.add_range(1..=2); // Lines 1-2
453-
ranges.add_range(1..=1); // Duplicate range, should be ignored
454-
ranges.add_range(4..=4); // Line 4
451+
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![
452+
1..=2, // Lines 1-2
453+
1..=1, // Duplicate range, should be ignored
454+
4..=4, // Line 4
455+
])
456+
.unwrap();
455457

456458
let source_file_name: gix_object::bstr::BString = "simple.txt".into();
457459

@@ -492,7 +494,7 @@ mod blame_ranges {
492494
suspect,
493495
} = Fixture::new()?;
494496

495-
let ranges = BlameRanges::from_ranges(vec![1..=2, 1..=1, 4..=4]);
497+
let ranges = BlameRanges::from_one_based_inclusive_ranges(vec![1..=2, 1..=1, 4..=4]).unwrap();
496498

497499
let source_file_name: gix_object::bstr::BString = "simple.txt".into();
498500

0 commit comments

Comments
 (0)