Skip to content

Commit 249162a

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 249162a

File tree

5 files changed

+210
-64
lines changed

5 files changed

+210
-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/file/tests.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,3 +1338,132 @@ mod process_changes {
13381338
);
13391339
}
13401340
}
1341+
1342+
mod blame_ranges {
1343+
use crate::BlameRanges;
1344+
1345+
#[test]
1346+
fn create_from_single_range() {
1347+
let range = BlameRanges::from_range(20..=40);
1348+
match range {
1349+
BlameRanges::OneBasedInclusive(ranges) => {
1350+
assert_eq!(ranges.len(), 1);
1351+
assert_eq!(ranges[0], 20..=40);
1352+
}
1353+
_ => panic!("Expected OneBasedInclusive variant"),
1354+
}
1355+
}
1356+
1357+
#[test]
1358+
fn create_from_multiple_ranges() {
1359+
let ranges = BlameRanges::from_ranges(vec![1..=4, 10..=14]);
1360+
match ranges {
1361+
BlameRanges::OneBasedInclusive(ranges) => {
1362+
assert_eq!(ranges.len(), 2);
1363+
assert_eq!(ranges[0], 1..=4);
1364+
assert_eq!(ranges[1], 10..=14);
1365+
}
1366+
_ => panic!("Expected OneBasedInclusive variant"),
1367+
}
1368+
}
1369+
1370+
#[test]
1371+
fn add_range_merges_overlapping() {
1372+
let mut ranges = BlameRanges::from_range(1..=5);
1373+
ranges.add_range(3..=7).unwrap();
1374+
1375+
match ranges {
1376+
BlameRanges::OneBasedInclusive(ranges) => {
1377+
assert_eq!(ranges.len(), 1);
1378+
assert_eq!(ranges[0], 1..=7);
1379+
}
1380+
_ => panic!("Expected OneBasedInclusive variant"),
1381+
}
1382+
}
1383+
1384+
#[test]
1385+
fn add_range_merges_adjacent() {
1386+
let mut ranges = BlameRanges::from_range(1..=5);
1387+
ranges.add_range(6..=10).unwrap();
1388+
1389+
match ranges {
1390+
BlameRanges::OneBasedInclusive(ranges) => {
1391+
assert_eq!(ranges.len(), 1);
1392+
assert_eq!(ranges[0], 1..=10);
1393+
}
1394+
_ => panic!("Expected OneBasedInclusive variant"),
1395+
}
1396+
}
1397+
1398+
#[test]
1399+
fn add_range_keeps_separate() {
1400+
let mut ranges = BlameRanges::from_range(1..=5);
1401+
ranges.add_range(7..=10).unwrap();
1402+
1403+
match ranges {
1404+
BlameRanges::OneBasedInclusive(ranges) => {
1405+
assert_eq!(ranges.len(), 2);
1406+
assert_eq!(ranges[0], 1..=5);
1407+
assert_eq!(ranges[1], 7..=10);
1408+
}
1409+
_ => panic!("Expected OneBasedInclusive variant"),
1410+
}
1411+
}
1412+
1413+
#[test]
1414+
fn convert_to_zero_based_exclusive() {
1415+
let ranges = BlameRanges::from_ranges(vec![1..=5, 10..=15]);
1416+
let result = ranges.to_zero_based_exclusive(20).unwrap();
1417+
match result {
1418+
BlameRanges::ZeroBasedExclusive(ranges) => {
1419+
assert_eq!(ranges.len(), 2);
1420+
assert_eq!(ranges[0], 0..5);
1421+
assert_eq!(ranges[1], 9..15);
1422+
}
1423+
_ => panic!("Expected ZeroBasedExclusive variant"),
1424+
}
1425+
}
1426+
1427+
#[test]
1428+
fn convert_full_file_to_zero_based() {
1429+
let ranges = BlameRanges::FullFile;
1430+
let result = ranges.to_zero_based_exclusive(100).unwrap();
1431+
match result {
1432+
BlameRanges::ZeroBasedExclusive(ranges) => {
1433+
assert_eq!(ranges.len(), 1);
1434+
assert_eq!(ranges[0], 0..100);
1435+
}
1436+
_ => panic!("Expected ZeroBasedExclusive variant"),
1437+
}
1438+
}
1439+
1440+
#[test]
1441+
fn convert_zero_based_to_zero_based() {
1442+
let ranges = BlameRanges::ZeroBasedExclusive(vec![0..5, 10..15]);
1443+
let result = ranges.to_zero_based_exclusive(20).unwrap();
1444+
match result {
1445+
BlameRanges::ZeroBasedExclusive(ranges) => {
1446+
assert_eq!(ranges.len(), 2);
1447+
assert_eq!(ranges[0], 0..5);
1448+
assert_eq!(ranges[1], 10..15);
1449+
}
1450+
_ => panic!("Expected ZeroBasedExclusive variant"),
1451+
}
1452+
}
1453+
1454+
#[test]
1455+
fn error_on_range_beyond_max_lines() {
1456+
let ranges = BlameRanges::from_range(1..=15);
1457+
let result = ranges.to_zero_based_exclusive(10);
1458+
assert!(result.is_err());
1459+
}
1460+
1461+
#[test]
1462+
fn default_is_full_file() {
1463+
let ranges = BlameRanges::default();
1464+
match ranges {
1465+
BlameRanges::FullFile => (),
1466+
_ => panic!("Expected FullFile variant"),
1467+
}
1468+
}
1469+
}

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)