diff --git a/src/display/inline.rs b/src/display/inline.rs index d04eed8dd9..2c66c8c024 100644 --- a/src/display/inline.rs +++ b/src/display/inline.rs @@ -1,5 +1,7 @@ //! Inline, or "unified" diff display. +use line_numbers::LineNumber; + use crate::constants::Side; use crate::display::context::{ calculate_after_context, calculate_before_context, opposite_positions, @@ -11,6 +13,50 @@ use crate::options::DisplayOptions; use crate::parse::syntax::MatchedPos; use crate::summary::FileFormat; +/// Expand `hunk.lines` to include any unchanged lines that fall between +/// changed lines on the same side. Without this, inline display drops +/// in-hunk context, which can hide section headings and similar +/// structural lines (issue #978). +fn fill_in_hunk_gaps( + lines: &[(Option, Option)], +) -> Vec<(Option, Option)> { + let mut filled = Vec::with_capacity(lines.len()); + + let mut prev_lhs: Option = None; + let mut prev_rhs: Option = None; + + for (lhs_line, rhs_line) in lines { + let lhs_gap: Vec = match (prev_lhs, *lhs_line) { + (Some(prev), Some(curr)) if prev.0 + 1 < curr.0 => { + (prev.0 + 1..curr.0).map(LineNumber::from).collect() + } + _ => vec![], + }; + let rhs_gap: Vec = match (prev_rhs, *rhs_line) { + (Some(prev), Some(curr)) if prev.0 + 1 < curr.0 => { + (prev.0 + 1..curr.0).map(LineNumber::from).collect() + } + _ => vec![], + }; + + let pair_count = lhs_gap.len().max(rhs_gap.len()); + for i in 0..pair_count { + filled.push((lhs_gap.get(i).copied(), rhs_gap.get(i).copied())); + } + + filled.push((*lhs_line, *rhs_line)); + + if lhs_line.is_some() { + prev_lhs = *lhs_line; + } + if rhs_line.is_some() { + prev_rhs = *rhs_line; + } + } + + filled +} + pub(crate) fn print( lhs_src: &str, rhs_src: &str, @@ -77,7 +123,7 @@ pub(crate) fn print( ) ); - let hunk_lines = hunk.lines.clone(); + let hunk_lines = fill_in_hunk_gaps(&hunk.lines); let before_lines = calculate_before_context( &hunk_lines, @@ -112,11 +158,12 @@ pub(crate) fn print( for (lhs_line, _) in &hunk_lines { if let Some(lhs_line) = lhs_line { + let is_novel = hunk.novel_lhs.contains(lhs_line); print!( "{} {}", apply_line_number_color( &format_line_num(*lhs_line), - true, + is_novel, Side::Left, display_options, ), @@ -126,11 +173,12 @@ pub(crate) fn print( } for (_, rhs_line) in &hunk_lines { if let Some(rhs_line) = rhs_line { + let is_novel = hunk.novel_rhs.contains(rhs_line); print!( " {}{}", apply_line_number_color( &format_line_num(*rhs_line), - true, + is_novel, Side::Right, display_options, ), @@ -156,3 +204,92 @@ pub(crate) fn print( println!(); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn ln(n: u32) -> LineNumber { + n.into() + } + + #[test] + fn fill_in_hunk_gaps_empty() { + assert!(fill_in_hunk_gaps(&[]).is_empty()); + } + + #[test] + fn fill_in_hunk_gaps_no_gap() { + let input = vec![(Some(ln(0)), Some(ln(0))), (Some(ln(1)), Some(ln(1)))]; + assert_eq!(fill_in_hunk_gaps(&input), input); + } + + #[test] + fn fill_in_hunk_gaps_lhs_only_change_then_unchanged() { + // LHS has a removed line at 0, unchanged lines at 1, 2 (both + // sides), then an RHS-only added line at index 1. Filling the + // gap inside the hunk should insert the unchanged lines that + // sit between novel changes. + let input = vec![(Some(ln(0)), None), (Some(ln(3)), Some(ln(3)))]; + let filled = fill_in_hunk_gaps(&input); + assert_eq!( + filled, + vec![ + (Some(ln(0)), None), + (Some(ln(1)), None), + (Some(ln(2)), None), + (Some(ln(3)), Some(ln(3))), + ] + ); + } + + #[test] + fn fill_in_hunk_gaps_section_header_between_changes() { + // Mimics issue #978: a hunk that contains LHS-only and RHS-only + // changes separated by lines that are unchanged on both sides. + // The unchanged lines are not in the input. After filling, each + // side's gap is rebuilt so the inline display can print them as + // in-hunk context. + let input = vec![ + (Some(ln(4)), None), + (None, Some(ln(4))), + (Some(ln(7)), None), + (None, Some(ln(7))), + ]; + let filled = fill_in_hunk_gaps(&input); + assert_eq!( + filled, + vec![ + (Some(ln(4)), None), + (None, Some(ln(4))), + (Some(ln(5)), None), + (Some(ln(6)), None), + (Some(ln(7)), None), + (None, Some(ln(5))), + (None, Some(ln(6))), + (None, Some(ln(7))), + ] + ); + } + + #[test] + fn fill_in_hunk_gaps_unequal_gap_lengths() { + // The RHS gap (lines 1..6 = 5 lines) is longer than the LHS gap + // (lines 1..3 = 2 lines). The filler pairs them up and pads the + // shorter side with None. + let input = vec![(Some(ln(0)), Some(ln(0))), (Some(ln(3)), Some(ln(6)))]; + let filled = fill_in_hunk_gaps(&input); + assert_eq!( + filled, + vec![ + (Some(ln(0)), Some(ln(0))), + (Some(ln(1)), Some(ln(1))), + (Some(ln(2)), Some(ln(2))), + (None, Some(ln(3))), + (None, Some(ln(4))), + (None, Some(ln(5))), + (Some(ln(3)), Some(ln(6))), + ] + ); + } +}