Skip to content

Commit 2201fa2

Browse files
committed
Refactor BlameRanges to support multiple range formats as an enum
This modification introduces changes to the `BlameRanges` struct, converting it into an enum to support both `OneBasedInclusive` and `ZeroBasedExclusive` range formats. The `FullFile` variant, denoting that the entire file is to be blamed, serves as a more explicit substitute for a previously used "empty" range.
1 parent 4661a7b commit 2201fa2

File tree

6 files changed

+211
-64
lines changed

6 files changed

+211
-64
lines changed

gix-blame/src/error.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ pub enum Error {
2828
#[error(transparent)]
2929
DiffTree(#[from] gix_diff::tree::Error),
3030
#[error("Invalid line range was given, line range is expected to be a 1-based inclusive range in the format '<start>,<end>'")]
31-
InvalidLineRange,
31+
InvalidOneBasedLineRange,
32+
#[error("Invalid line range was given, line range is expected to be a 0-based inclusive range in the format '<start>,<end>'")]
33+
InvalidZeroBasedLineRange,
3234
#[error("Failure to decode commit during traversal")]
3335
DecodeCommit(#[from] gix_object::decode::Error),
3436
#[error("Failed to get parent from commitgraph during traversal")]

gix-blame/src/file/function.rs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use gix_traverse::commit::find as find_commit;
1010
use smallvec::SmallVec;
1111

1212
use super::{process_changes, Change, UnblamedHunk};
13-
use crate::{BlameEntry, Error, Options, Outcome, Statistics};
13+
use crate::{BlameEntry, BlameRanges, Error, Options, Outcome, Statistics};
1414

1515
/// Produce a list of consecutive [`BlameEntry`] instances to indicate in which commits the ranges of the file
1616
/// at `suspect:<file_path>` originated in.
@@ -94,15 +94,14 @@ pub fn file(
9494
return Ok(Outcome::default());
9595
}
9696

97-
let ranges = options.range.to_zero_based_exclusive(num_lines_in_blamed)?;
98-
let mut hunks_to_blame = Vec::with_capacity(ranges.len());
99-
100-
for range in ranges {
101-
hunks_to_blame.push(UnblamedHunk {
102-
range_in_blamed_file: range.clone(),
103-
suspects: [(suspect, range)].into(),
104-
});
105-
}
97+
let zero_based_ranges = options.ranges.to_zero_based_exclusive(num_lines_in_blamed)?;
98+
let mut hunks_to_blame = match zero_based_ranges {
99+
BlameRanges::ZeroBasedExclusive(ranges) => ranges
100+
.into_iter()
101+
.map(|range| UnblamedHunk::new(range, suspect))
102+
.collect::<Vec<_>>(),
103+
_ => return Err(Error::InvalidZeroBasedLineRange),
104+
};
106105

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

gix-blame/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,6 @@ mod types;
2020
pub use types::{BlameEntry, BlameRanges, Options, Outcome, Statistics};
2121

2222
mod file;
23+
mod tests;
24+
2325
pub use file::function::file;

gix-blame/src/tests.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
use crate::types::BlameRanges;
2+
3+
mod blame_ranges {
4+
#[test]
5+
fn create_from_single_range() {
6+
let range = BlameRanges::from_range(20..=40);
7+
match range {
8+
BlameRanges::OneBasedInclusive(ranges) => {
9+
assert_eq!(ranges.len(), 1);
10+
assert_eq!(ranges[0], 20..=40);
11+
}
12+
_ => panic!("Expected OneBasedInclusive variant"),
13+
}
14+
}
15+
16+
#[test]
17+
fn create_from_multiple_ranges() {
18+
let ranges = BlameRanges::from_ranges(vec![1..=4, 10..=14]);
19+
match ranges {
20+
BlameRanges::OneBasedInclusive(ranges) => {
21+
assert_eq!(ranges.len(), 2);
22+
assert_eq!(ranges[0], 1..=4);
23+
assert_eq!(ranges[1], 10..=14);
24+
}
25+
_ => panic!("Expected OneBasedInclusive variant"),
26+
}
27+
}
28+
29+
#[test]
30+
fn add_range_merges_overlapping() {
31+
let mut ranges = BlameRanges::from_range(1..=5);
32+
ranges.add_range(3..=7).unwrap();
33+
34+
match ranges {
35+
BlameRanges::OneBasedInclusive(ranges) => {
36+
assert_eq!(ranges.len(), 1);
37+
assert_eq!(ranges[0], 1..=7);
38+
}
39+
_ => panic!("Expected OneBasedInclusive variant"),
40+
}
41+
}
42+
43+
#[test]
44+
fn add_range_merges_adjacent() {
45+
let mut ranges = BlameRanges::from_range(1..=5);
46+
ranges.add_range(6..=10).unwrap();
47+
48+
match ranges {
49+
BlameRanges::OneBasedInclusive(ranges) => {
50+
assert_eq!(ranges.len(), 1);
51+
assert_eq!(ranges[0], 1..=10);
52+
}
53+
_ => panic!("Expected OneBasedInclusive variant"),
54+
}
55+
}
56+
57+
#[test]
58+
fn add_range_keeps_separate() {
59+
let mut ranges = BlameRanges::from_range(1..=5);
60+
ranges.add_range(7..=10).unwrap();
61+
62+
match ranges {
63+
BlameRanges::OneBasedInclusive(ranges) => {
64+
assert_eq!(ranges.len(), 2);
65+
assert_eq!(ranges[0], 1..=5);
66+
assert_eq!(ranges[1], 7..=10);
67+
}
68+
_ => panic!("Expected OneBasedInclusive variant"),
69+
}
70+
}
71+
72+
#[test]
73+
fn convert_to_zero_based_exclusive() {
74+
let ranges = BlameRanges::from_ranges(vec![1..=5, 10..=15]);
75+
let result = ranges.to_zero_based_exclusive(20).unwrap();
76+
match result {
77+
BlameRanges::ZeroBasedExclusive(ranges) => {
78+
assert_eq!(ranges.len(), 2);
79+
assert_eq!(ranges[0], 0..5);
80+
assert_eq!(ranges[1], 9..15);
81+
}
82+
_ => panic!("Expected ZeroBasedExclusive variant"),
83+
}
84+
}
85+
86+
#[test]
87+
fn convert_full_file_to_zero_based() {
88+
let ranges = BlameRanges::FullFile;
89+
let result = ranges.to_zero_based_exclusive(100).unwrap();
90+
match result {
91+
BlameRanges::ZeroBasedExclusive(ranges) => {
92+
assert_eq!(ranges.len(), 1);
93+
assert_eq!(ranges[0], 0..100);
94+
}
95+
_ => panic!("Expected ZeroBasedExclusive variant"),
96+
}
97+
}
98+
99+
#[test]
100+
fn convert_zero_based_to_zero_based() {
101+
let ranges = BlameRanges::ZeroBasedExclusive(vec![0..5, 10..15]);
102+
let result = ranges.to_zero_based_exclusive(20).unwrap();
103+
match result {
104+
BlameRanges::ZeroBasedExclusive(ranges) => {
105+
assert_eq!(ranges.len(), 2);
106+
assert_eq!(ranges[0], 0..5);
107+
assert_eq!(ranges[1], 10..15);
108+
}
109+
_ => panic!("Expected ZeroBasedExclusive variant"),
110+
}
111+
}
112+
113+
#[test]
114+
fn error_on_range_beyond_max_lines() {
115+
let ranges = BlameRanges::from_range(1..=15);
116+
let result = ranges.to_zero_based_exclusive(10);
117+
assert!(result.is_err());
118+
}
119+
120+
#[test]
121+
fn default_is_full_file() {
122+
let ranges = BlameRanges::default();
123+
match ranges {
124+
BlameRanges::FullFile => (),
125+
_ => panic!("Expected FullFile variant"),
126+
}
127+
}
128+
}

gix-blame/src/types.rs

Lines changed: 64 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ use crate::Error;
2424
/// let range = BlameRanges::from_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_ranges(vec![
28+
/// 1..=4, // Lines 1-4
29+
/// 10..=14, // Lines 10-14
30+
/// ]
31+
/// );
3032
/// ```
3133
///
3234
/// # Line Number Representation
@@ -40,36 +42,33 @@ use crate::Error;
4042
/// An empty `BlameRanges` (created via `BlameRanges::new()` or `BlameRanges::default()`) means
4143
/// to blame the entire file, similar to running `git blame` without line number arguments.
4244
#[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>>,
45+
pub enum BlameRanges {
46+
/// Blame the entire file.
47+
#[default]
48+
FullFile,
49+
/// Blame ranges in 1-based inclusive format.
50+
OneBasedInclusive(Vec<RangeInclusive<u32>>),
51+
/// Blame ranges in 0-based exclusive format.
52+
ZeroBasedExclusive(Vec<Range<u32>>),
4753
}
4854

4955
/// Lifecycle
5056
impl BlameRanges {
51-
/// Create a new empty BlameRanges instance.
52-
///
53-
/// An empty instance means to blame the entire file.
54-
pub fn new() -> Self {
55-
Self::default()
56-
}
57-
5857
/// Create from a single range.
5958
///
6059
/// The range is 1-based, similar to git's line number format.
6160
pub fn from_range(range: RangeInclusive<u32>) -> Self {
62-
Self { ranges: vec![range] }
61+
Self::OneBasedInclusive(vec![range])
6362
}
6463

6564
/// Create from multiple ranges.
6665
///
6766
/// All ranges are 1-based.
6867
/// Overlapping or adjacent ranges will be merged.
6968
pub fn from_ranges(ranges: Vec<RangeInclusive<u32>>) -> Self {
70-
let mut result = Self::new();
69+
let mut result = Self::OneBasedInclusive(vec![]);
7170
for range in ranges {
72-
result.merge_range(range);
71+
let _ = result.merge_range(range);
7372
}
7473
result
7574
}
@@ -81,23 +80,32 @@ impl BlameRanges {
8180
/// The range should be 1-based inclusive.
8281
/// If the new range overlaps with or is adjacent to an existing range,
8382
/// 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);
83+
pub fn add_range(&mut self, new_range: RangeInclusive<u32>) -> Result<(), Error> {
84+
match self {
85+
Self::OneBasedInclusive(_) => self.merge_range(new_range),
86+
_ => Err(Error::InvalidOneBasedLineRange),
87+
}
8688
}
8789

8890
/// Attempts to merge the new range with any existing ranges.
8991
/// 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;
92+
fn merge_range(&mut self, new_range: RangeInclusive<u32>) -> Result<(), Error> {
93+
match self {
94+
Self::OneBasedInclusive(ref mut ranges) => {
95+
// Check if this range can be merged with any existing range
96+
for range in &mut *ranges {
97+
// Check if ranges overlap or are adjacent
98+
if new_range.start() <= &(range.end() + 1) && range.start() <= &(new_range.end() + 1) {
99+
*range = *range.start().min(new_range.start())..=*range.end().max(new_range.end());
100+
return Ok(());
101+
}
102+
}
103+
// If no overlap found, add it as a new range
104+
ranges.push(new_range);
105+
Ok(())
97106
}
107+
_ => Err(Error::InvalidOneBasedLineRange),
98108
}
99-
// If no overlap found, add it as a new range
100-
self.ranges.push(new_range);
101109
}
102110

103111
/// Convert the 1-based inclusive ranges to 0-based exclusive ranges.
@@ -111,30 +119,26 @@ impl BlameRanges {
111119
/// - Any range starts at 0 (must be 1-based)
112120
/// - Any range extends beyond the file's length
113121
/// - 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-
}
119-
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);
122+
pub fn to_zero_based_exclusive(&self, max_lines: u32) -> Result<BlameRanges, Error> {
123+
match self {
124+
Self::FullFile => {
125+
let range = 0..max_lines;
126+
Ok(BlameRanges::ZeroBasedExclusive(vec![range]))
124127
}
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);
128+
Self::OneBasedInclusive(ranges) => {
129+
let mut result = Vec::with_capacity(ranges.len());
130+
for range in ranges {
131+
let start = range.start() - 1;
132+
let end = *range.end();
133+
if start >= max_lines || end > max_lines || start == end {
134+
return Err(Error::InvalidOneBasedLineRange);
135+
}
136+
result.push(start..end);
137+
}
138+
Ok(BlameRanges::ZeroBasedExclusive(result))
129139
}
130-
result.push(start..end);
140+
Self::ZeroBasedExclusive(_) => Ok(self.clone()),
131141
}
132-
Ok(result)
133-
}
134-
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()
138142
}
139143
}
140144

@@ -334,6 +338,17 @@ pub struct UnblamedHunk {
334338
}
335339

336340
impl UnblamedHunk {
341+
/// Create a new instance
342+
pub fn new(range: Range<u32>, suspect: ObjectId) -> Self {
343+
let range_start = range.start;
344+
let range_end = range.end;
345+
346+
UnblamedHunk {
347+
range_in_blamed_file: range_start..range_end,
348+
suspects: [(suspect, range_start..range_end)].into(),
349+
}
350+
}
351+
337352
pub(crate) fn has_suspect(&self, suspect: &ObjectId) -> bool {
338353
self.suspects.iter().any(|entry| entry.0 == *suspect)
339354
}

gix-blame/tests/blame.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,11 @@ mod blame_ranges {
355355
suspect,
356356
} = Fixture::new().unwrap();
357357

358-
let mut ranges = BlameRanges::new();
359-
ranges.add_range(1..=2); // Lines 1-2
360-
ranges.add_range(1..=1); // Duplicate range, should be ignored
361-
ranges.add_range(4..=4); // Line 4
358+
let ranges = BlameRanges::from_ranges(vec![
359+
1..=2, // Lines 1-2
360+
1..=1, // Duplicate range, should be ignored
361+
4..=4, // Line 4
362+
]);
362363

363364
let lines_blamed = gix_blame::file(
364365
&odb,

0 commit comments

Comments
 (0)