diff --git a/CHANGELOG.md b/CHANGELOG.md index 69338d36..99ad6c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add paging to `--list-themes`, see PR #3239 (@einfachIrgendwer0815) - Support negative relative line ranges, e.g. `bat -r :-10` / `bat -r='-10:'`, see #3068 (@ajesipow) +- Support context in line ranges, e.g. `bat -r 30::5` / `bat -r 30:40:5`, see #3345 (@cavanaug) ## Bugfixes diff --git a/doc/long-help.txt b/doc/long-help.txt index 17d3395b..231b711e 100644 --- a/doc/long-help.txt +++ b/doc/long-help.txt @@ -194,6 +194,8 @@ Options: '--line-range 40:' prints lines 40 to the end of the file '--line-range 40' only prints line 40 '--line-range 30:+10' prints lines 30 to 40 + '--line-range 35::5' prints lines 30 to 40 (line 35 with 5 lines of context) + '--line-range 30:40:2' prints lines 28 to 42 (range 30-40 with 2 lines of context) -L, --list-languages Display a list of supported languages for syntax highlighting. diff --git a/src/bin/bat/clap_app.rs b/src/bin/bat/clap_app.rs index d417d4a7..7aa1ef20 100644 --- a/src/bin/bat/clap_app.rs +++ b/src/bin/bat/clap_app.rs @@ -525,7 +525,9 @@ pub fn build_app(interactive_output: bool) -> Command { '--line-range :40' prints lines 1 to 40\n \ '--line-range 40:' prints lines 40 to the end of the file\n \ '--line-range 40' only prints line 40\n \ - '--line-range 30:+10' prints lines 30 to 40", + '--line-range 30:+10' prints lines 30 to 40\n \ + '--line-range 35::5' prints lines 30 to 40 (line 35 with 5 lines of context)\n \ + '--line-range 30:40:2' prints lines 28 to 42 (range 30-40 with 2 lines of context)", ), ) .arg( diff --git a/src/line_range.rs b/src/line_range.rs index a6ec22c2..3d15c607 100644 --- a/src/line_range.rs +++ b/src/line_range.rs @@ -98,8 +98,33 @@ impl LineRange { new_range.upper = RangeBound::Absolute(upper_absolute_bound); Ok(new_range) } + 3 => { + // Handle context syntax: N::C or N:M:C + if line_numbers[1].is_empty() { + // Format: N::C - single line with context + let line_number: usize = line_numbers[0].parse() + .map_err(|_| "Invalid line number in N::C format")?; + let context: usize = line_numbers[2].parse() + .map_err(|_| "Invalid context number in N::C format")?; + + new_range.lower = RangeBound::Absolute(line_number.saturating_sub(context)); + new_range.upper = RangeBound::Absolute(line_number.saturating_add(context)); + } else { + // Format: N:M:C - range with context + let start_line: usize = line_numbers[0].parse() + .map_err(|_| "Invalid start line number in N:M:C format")?; + let end_line: usize = line_numbers[1].parse() + .map_err(|_| "Invalid end line number in N:M:C format")?; + let context: usize = line_numbers[2].parse() + .map_err(|_| "Invalid context number in N:M:C format")?; + + new_range.lower = RangeBound::Absolute(start_line.saturating_sub(context)); + new_range.upper = RangeBound::Absolute(end_line.saturating_add(context)); + } + Ok(new_range) + } _ => Err( - "Line range contained more than one ':' character. Expected format: 'N' or 'N:M'" + "Line range contained too many ':' characters. Expected format: 'N', 'N:M', 'N::C', or 'N:M:C'" .into(), ), } @@ -210,14 +235,17 @@ fn test_parse_single() { #[test] fn test_parse_fail() { - let range = LineRange::from("40:50:80"); - assert!(range.is_err()); - let range = LineRange::from("40::80"); + // Test 4+ colon parts should still fail + let range = LineRange::from("40:50:80:90"); assert!(range.is_err()); + // Test invalid formats that should still fail let range = LineRange::from("-2:5"); assert!(range.is_err()); let range = LineRange::from(":40:"); assert!(range.is_err()); + // Test completely malformed input + let range = LineRange::from("abc:def"); + assert!(range.is_err()); } #[test] @@ -274,6 +302,57 @@ fn test_parse_minus_fail() { assert!(range.is_err()); } +#[test] +fn test_parse_context_single_line() { + let range = LineRange::from("35::5").expect("Shouldn't fail on test!"); + assert_eq!(RangeBound::Absolute(30), range.lower); + assert_eq!(RangeBound::Absolute(40), range.upper); +} + +#[test] +fn test_parse_context_range() { + let range = LineRange::from("30:40:2").expect("Shouldn't fail on test!"); + assert_eq!(RangeBound::Absolute(28), range.lower); + assert_eq!(RangeBound::Absolute(42), range.upper); + + // Test the case that used to fail but should now work + let range = LineRange::from("40:50:80").expect("Shouldn't fail on test!"); + assert_eq!(RangeBound::Absolute(0), range.lower); // 40 - 80 = 0 (saturated) + assert_eq!(RangeBound::Absolute(130), range.upper); // 50 + 80 = 130 +} + +#[test] +fn test_parse_context_edge_cases() { + // Test with small line numbers that would underflow + let range = LineRange::from("5::10").expect("Shouldn't fail on test!"); + assert_eq!(RangeBound::Absolute(0), range.lower); + assert_eq!(RangeBound::Absolute(15), range.upper); + + // Test with zero context + let range = LineRange::from("50::0").expect("Shouldn't fail on test!"); + assert_eq!(RangeBound::Absolute(50), range.lower); + assert_eq!(RangeBound::Absolute(50), range.upper); + + // Test range with zero context + let range = LineRange::from("30:40:0").expect("Shouldn't fail on test!"); + assert_eq!(RangeBound::Absolute(30), range.lower); + assert_eq!(RangeBound::Absolute(40), range.upper); +} + +#[test] +fn test_parse_context_fail() { + let range = LineRange::from("40::z"); + assert!(range.is_err()); + let range = LineRange::from("::5"); + assert!(range.is_err()); + let range = LineRange::from("40::"); + assert!(range.is_err()); + let range = LineRange::from("30:40:z"); + assert!(range.is_err()); + let range = LineRange::from("30::40:5"); + assert!(range.is_err()); +} + #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum RangeCheckResult { // Within one of the given ranges diff --git a/tests/examples/multiline.txt b/tests/examples/multiline.txt index 9c2a7090..fa2da6e5 100644 --- a/tests/examples/multiline.txt +++ b/tests/examples/multiline.txt @@ -2,3 +2,9 @@ line 1 line 2 line 3 line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 7579794d..d9f8b403 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -163,7 +163,7 @@ fn line_numbers() { .arg("--decorations=always") .assert() .success() - .stdout(" 1 line 1\n 2 line 2\n 3 line 3\n 4 line 4\n"); + .stdout(" 1 line 1\n 2 line 2\n 3 line 3\n 4 line 4\n 5 line 5\n 6 line 6\n 7 line 7\n 8 line 8\n 9 line 9\n 10 line 10\n"); } #[test] @@ -183,7 +183,7 @@ fn line_range_up_to_2_from_back() { .arg("--line-range=:-2") .assert() .success() - .stdout("line 1\nline 2\n"); + .stdout("line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n"); } #[test] @@ -203,7 +203,7 @@ fn line_range_from_back_last_two() { .arg("--line-range=-2:") .assert() .success() - .stdout("line 3\nline 4\n"); + .stdout("line 9\nline 10\n"); } #[test] @@ -230,10 +230,10 @@ fn line_range_first_two() { fn line_range_last_3() { bat() .arg("multiline.txt") - .arg("--line-range=2:") + .arg("--line-range=8:") .assert() .success() - .stdout("line 2\nline 3\nline 4\n"); + .stdout("line 8\nline 9\nline 10\n"); } #[test] @@ -247,6 +247,137 @@ fn line_range_multiple() { .stdout("line 1\nline 2\nline 4\n"); } +#[test] +fn line_range_multiple_with_context() { + bat() + .arg("multiline.txt") + .arg("--line-range=2::1") + .arg("--line-range=8::1") + .assert() + .success() + .stdout("line 1\nline 2\nline 3\nline 7\nline 8\nline 9\n"); +} + +#[test] +fn line_range_context_around_single_line() { + bat() + .arg("multiline.txt") + .arg("--line-range=5::2") + .assert() + .success() + .stdout("line 3\nline 4\nline 5\nline 6\nline 7\n"); +} + +#[test] +fn line_range_context_around_single_line_minimal() { + bat() + .arg("multiline.txt") + .arg("--line-range=5::1") + .assert() + .success() + .stdout("line 4\nline 5\nline 6\n"); +} + +#[test] +fn line_range_context_around_range() { + bat() + .arg("multiline.txt") + .arg("--line-range=4:6:2") + .assert() + .success() + .stdout("line 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\n"); +} + +#[test] +fn line_range_context_at_file_boundaries() { + bat() + .arg("multiline.txt") + .arg("--line-range=1::2") + .assert() + .success() + .stdout("line 1\nline 2\nline 3\n"); +} + +#[test] +fn line_range_context_at_end_of_file() { + bat() + .arg("multiline.txt") + .arg("--line-range=10::2") + .assert() + .success() + .stdout("line 8\nline 9\nline 10\n"); +} + +#[test] +fn line_range_context_zero() { + bat() + .arg("multiline.txt") + .arg("--line-range=5::0") + .assert() + .success() + .stdout("line 5\n"); +} + +#[test] +fn line_range_context_negative_single_line() { + bat() + .arg("multiline.txt") + .arg("--line-range=5::-1") + .assert() + .failure() + .stderr(predicate::str::contains( + "Invalid context number in N::C format", + )); +} + +#[test] +fn line_range_context_negative_range() { + bat() + .arg("multiline.txt") + .arg("--line-range=5:6:-1") + .assert() + .failure() + .stderr(predicate::str::contains( + "Invalid context number in N:M:C format", + )); +} + +#[test] +fn line_range_context_non_numeric_single_line() { + bat() + .arg("multiline.txt") + .arg("--line-range=10::abc") + .assert() + .failure() + .stderr(predicate::str::contains( + "Invalid context number in N::C format", + )); +} + +#[test] +fn line_range_context_non_numeric_range() { + bat() + .arg("multiline.txt") + .arg("--line-range=10:12:xyz") + .assert() + .failure() + .stderr(predicate::str::contains( + "Invalid context number in N:M:C format", + )); +} + +#[test] +fn line_range_context_very_large() { + bat() + .arg("multiline.txt") + .arg("--line-range=10::999999") + .assert() + .success() + .stdout( + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n", + ); +} + #[test] fn squeeze_blank() { bat() @@ -1523,6 +1654,12 @@ fn snip() { 2 line 2 ...─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 8< ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 4 line 4 + 5 line 5 + 6 line 6 + 7 line 7 + 8 line 8 + 9 line 9 + 10 line 10 ", ); }