//! Side-by-side (two column) display of diffs. use std::cmp::{max, min}; use line_numbers::{LineNumber, SingleLineSpan}; use owo_colors::{OwoColorize, Style}; use crate::constants::Side; use crate::display::context::all_matched_lines_filled; use crate::display::hunks::{matched_lines_indexes_for_hunk, Hunk}; use crate::display::style::{ self, apply_colors, apply_line_number_color, color_positions, novel_style, replace_tabs, split_and_apply, width_respecting_tabs, BackgroundColor, }; use crate::hash::{DftHashMap, DftHashSet}; use crate::lines::{format_line_num, format_line_num_padded, split_on_newlines}; use crate::options::{DisplayMode, DisplayOptions}; use crate::parse::syntax::{zip_pad_shorter, MatchedPos}; use crate::summary::FileFormat; /// The space shown between LHS and RHS columns. const SPACER: &str = " "; fn format_missing_line_num( prev_num: LineNumber, source_dims: &SourceDimensions, side: Side, is_continuation: bool, use_color: bool, ) -> String { let column_width = match side { Side::Left => source_dims.lhs_line_nums_width, Side::Right => source_dims.rhs_line_nums_width, }; let after_end = match side { Side::Left => prev_num >= source_dims.lhs_max_line_in_file, Side::Right => prev_num >= source_dims.rhs_max_line_in_file, }; let mut style = Style::new(); if use_color { style = style.dimmed(); } let c = if is_continuation { // Always show dots when this line is too long so we had to // split it over several lines when displaying. "." } else if after_end { // The file on this side has fewer lines, and we've already // shown the last ones. " " } else { // There are more lines in this file. "." }; let num_digits = prev_num.display().len(); format!( "{:>width$} ", c.repeat(num_digits), width = column_width - 1 ) .style(style) .to_string() } /// Display `src` in a single column (e.g. a file removal or addition). fn display_single_column( display_path: &str, old_path: Option<&String>, file_format: &FileFormat, src_lines: &[String], side: Side, display_options: &DisplayOptions, ) -> Vec { let column_width = format_line_num((src_lines.len() as u32).into()).len(); let mut formatted_lines = Vec::with_capacity(src_lines.len()); let mut header_line = String::new(); header_line.push_str(&style::header( display_path, old_path, 1, 1, file_format, display_options, )); header_line.push('\n'); formatted_lines.push(header_line); let mut style = Style::new(); if display_options.use_color { style = novel_style(Style::new(), side, display_options.background_color); } for (i, line) in src_lines.iter().enumerate() { let mut formatted_line = String::with_capacity(line.len()); formatted_line.push_str( &format_line_num_padded((i as u32).into(), column_width) .style(style) .to_string(), ); formatted_line.push_str(line); formatted_lines.push(formatted_line); } formatted_lines } fn display_line_nums( lhs_line_num: Option, rhs_line_num: Option, source_dims: &SourceDimensions, display_options: &DisplayOptions, lhs_has_novel: bool, rhs_has_novel: bool, prev_lhs_line_num: Option, prev_rhs_line_num: Option, ) -> (String, String) { let display_lhs_line_num: String = match lhs_line_num { Some(line_num) => { let s = format_line_num_padded(line_num, source_dims.lhs_line_nums_width); apply_line_number_color(&s, lhs_has_novel, Side::Left, display_options) } None => format_missing_line_num( prev_lhs_line_num.unwrap_or_else(|| 1.into()), source_dims, Side::Left, false, display_options.use_color, ), }; let display_rhs_line_num: String = match rhs_line_num { Some(line_num) => { let s = format_line_num_padded(line_num, source_dims.rhs_line_nums_width); apply_line_number_color(&s, rhs_has_novel, Side::Right, display_options) } None => format_missing_line_num( prev_rhs_line_num.unwrap_or_else(|| 1.into()), source_dims, Side::Right, false, display_options.use_color, ), }; (display_lhs_line_num, display_rhs_line_num) } // Sizes used when displaying a hunk. #[derive(Debug)] struct SourceDimensions { /// The number of characters used to display LHS source lines. Any /// line that exceeds this length will be wrapped. lhs_content_display_width: usize, /// The number of characters used to display RHS source lines. Any /// line that exceeds this length will be wrapped. rhs_content_display_width: usize, /// The number of characters required to display line numbers on /// the LHS. lhs_line_nums_width: usize, /// The number of characters required to display line numbers on /// the RHS. rhs_line_nums_width: usize, lhs_max_line_in_file: LineNumber, rhs_max_line_in_file: LineNumber, } impl SourceDimensions { fn new( terminal_width: usize, lhs_max_line_visible: LineNumber, rhs_max_line_visible: LineNumber, lhs_max_line_in_file: LineNumber, rhs_max_line_in_file: LineNumber, lhs_content_max_width: usize, rhs_content_max_width: usize, ) -> Self { let lhs_line_nums_width = format_line_num(lhs_max_line_visible).len(); let rhs_line_nums_width = format_line_num(rhs_max_line_visible).len(); let content_max_width = max(lhs_content_max_width, rhs_content_max_width); // If the file lines are extremely short, treat them as if // they have a line of 25 characters. let content_max_width = max(content_max_width, 25); // If the terminal is very wide, we don't want to use the full // 50% for the LHS column, we end up with too much space // between LHS and RHS. // // Instead, cap the display width based on the maximum length // of visible lines within the file. // // This naively assumes that byte length is the same as // display length, which is generally OK because byte length // will tend to be larger than the display length. Tab // characters are an exception, where display width exceeds // byte length, so we account for tab expansion explicitly. let width_without_truncation = lhs_line_nums_width + content_max_width + SPACER.len() + rhs_line_nums_width + content_max_width; let display_width = min(terminal_width, width_without_truncation); assert!( display_width > SPACER.len(), "Terminal total width should not overflow" ); let lhs_total_width = (display_width - SPACER.len()) / 2; let lhs_content_width = if lhs_line_nums_width < lhs_total_width { lhs_total_width - lhs_line_nums_width } else { // The terminal is so narrow that even the column numbers // display doesn't fit. Ensure we show a non-zero number // of content columns anyway. 1 }; let rhs_content_width = max( 1, display_width as isize - lhs_total_width as isize - SPACER.len() as isize - rhs_line_nums_width as isize, ) as usize; // We want the content width to be the same on both // sides. This ensures that line wrapping splits lines at the // same point on both sides. let content_width = min(lhs_content_width, rhs_content_width); Self { lhs_content_display_width: content_width, rhs_content_display_width: content_width, lhs_line_nums_width, rhs_line_nums_width, lhs_max_line_in_file, rhs_max_line_in_file, } } } pub(crate) fn lines_with_novel( lhs_mps: &[MatchedPos], rhs_mps: &[MatchedPos], ) -> (DftHashSet, DftHashSet) { let lhs_lines_with_novel: DftHashSet = lhs_mps .iter() .filter(|mp| mp.kind.is_novel()) .map(|mp| mp.pos.line) .collect(); let rhs_lines_with_novel: DftHashSet = rhs_mps .iter() .filter(|mp| mp.kind.is_novel()) .map(|mp| mp.pos.line) .collect(); (lhs_lines_with_novel, rhs_lines_with_novel) } /// Calculate positions of highlights on both sides. This includes /// both syntax highlighting and added/removed content highlighting. fn highlight_positions( background: BackgroundColor, syntax_highlight: bool, file_format: &FileFormat, lhs_mps: &[MatchedPos], rhs_mps: &[MatchedPos], ) -> ( DftHashMap>, DftHashMap>, ) { let lhs_positions = color_positions( Side::Left, background, syntax_highlight, file_format, lhs_mps, ); let mut lhs_styles: DftHashMap> = DftHashMap::default(); for (span, style) in lhs_positions { let styles = lhs_styles.entry(span.line).or_insert_with(Vec::new); styles.push((span, style)); } let rhs_positions = color_positions( Side::Right, background, syntax_highlight, file_format, rhs_mps, ); let mut rhs_styles: DftHashMap> = DftHashMap::default(); for (span, style) in rhs_positions { let styles = rhs_styles.entry(span.line).or_insert_with(Vec::new); styles.push((span, style)); } (lhs_styles, rhs_styles) } fn highlight_as_novel( line_num: Option, lines: &[&str], opposite_line_num: Option, lines_with_novel: &DftHashSet, ) -> bool { if let Some(line_num) = line_num { // If this line contains any novel tokens, highlight it. if lines_with_novel.contains(&line_num) { return true; } let line_content = lines.get(line_num.as_usize()).map(|s| str::trim(s)); // If this is a blank line without a corresponding line on the // other side, highlight it too. This helps highlight novel // blank lines. if line_content == Some("") && opposite_line_num.is_none() { return true; } } false } /// Find the longest line in `lhs_src` and `rhs_src` that will be /// displayed. Return the length of that line for both LHS and RHS. fn visible_content_max_display_width( lhs_src: &str, rhs_src: &str, hunks: &[Hunk], num_context_lines: u32, tab_width: usize, ) -> (usize, usize) { let mut lhs_displayed_lines: DftHashSet = DftHashSet::default(); let mut rhs_displayed_lines: DftHashSet = DftHashSet::default(); for hunk in hunks { let mut min_lhs_line: Option = None; let mut max_lhs_line: Option = None; let mut min_rhs_line: Option = None; let mut max_rhs_line: Option = None; for (lhs_line, rhs_line) in &hunk.lines { if let Some(lhs_line) = lhs_line { if let Some(current_min) = min_lhs_line { min_lhs_line = Some(min(current_min, *lhs_line)); } else { min_lhs_line = Some(*lhs_line); } if let Some(current_max) = max_lhs_line { max_lhs_line = Some(max(current_max, *lhs_line)); } else { max_lhs_line = Some(*lhs_line); } } if let Some(rhs_line) = rhs_line { if let Some(current_min) = min_rhs_line { min_rhs_line = Some(min(current_min, *rhs_line)); } else { min_rhs_line = Some(*rhs_line); } if let Some(current_max) = max_rhs_line { max_rhs_line = Some(max(current_max, *rhs_line)); } else { max_rhs_line = Some(*rhs_line); } } if let (Some(min_lhs_line), Some(max_lhs_line)) = (min_lhs_line, max_lhs_line) { let min_lhs_plus_padding = max(0, min_lhs_line.0 as isize - num_context_lines as isize) as usize; let max_lhs_plus_padding = max_lhs_line.0 as usize + num_context_lines as usize; for lhs_line_num in min_lhs_plus_padding..=max_lhs_plus_padding { lhs_displayed_lines.insert(lhs_line_num); } } if let (Some(min_rhs_line), Some(max_rhs_line)) = (min_rhs_line, max_rhs_line) { let min_rhs_plus_padding = max(0, min_rhs_line.0 as isize - num_context_lines as isize) as usize; let max_rhs_plus_padding = max_rhs_line.0 as usize + num_context_lines as usize; for rhs_line_num in min_rhs_plus_padding..=max_rhs_plus_padding { rhs_displayed_lines.insert(rhs_line_num); } } } } let mut lhs_content_max_width: usize = 0; let mut rhs_content_max_width: usize = 0; for (lhs_i, lhs_line) in lhs_src.lines().enumerate() { if lhs_displayed_lines.contains(&lhs_i) { lhs_content_max_width = max( lhs_content_max_width, width_respecting_tabs(lhs_line, tab_width), ); } } for (rhs_i, rhs_line) in rhs_src.lines().enumerate() { if rhs_displayed_lines.contains(&rhs_i) { rhs_content_max_width = max( rhs_content_max_width, width_respecting_tabs(rhs_line, tab_width), ); } } (lhs_content_max_width, rhs_content_max_width) } pub(crate) fn print( hunks: &[Hunk], display_options: &DisplayOptions, display_path: &str, old_path: Option<&String>, file_format: &FileFormat, lhs_src: &str, rhs_src: &str, lhs_mps: &[MatchedPos], rhs_mps: &[MatchedPos], ) { let (lhs_content_max_width, rhs_content_max_width) = visible_content_max_display_width( lhs_src, rhs_src, hunks, display_options.num_context_lines, display_options.tab_width, ); let (lhs_colored_lines, rhs_colored_lines) = if display_options.use_color { ( apply_colors( lhs_src, Side::Left, display_options.syntax_highlight, file_format, display_options.background_color, lhs_mps, ), apply_colors( rhs_src, Side::Right, display_options.syntax_highlight, file_format, display_options.background_color, rhs_mps, ), ) } else { ( split_on_newlines(lhs_src) .map(|s| format!("{}\n", s)) .collect(), split_on_newlines(rhs_src) .map(|s| format!("{}\n", s)) .collect(), ) }; // Style positions are relative to the source code offsets. Now // that we've applied styling, we can replace tabs. let lhs_colored_lines: Vec<_> = lhs_colored_lines .iter() .map(|l| replace_tabs(l, display_options.tab_width)) .collect(); let rhs_colored_lines: Vec<_> = rhs_colored_lines .iter() .map(|l| replace_tabs(l, display_options.tab_width)) .collect(); if lhs_src.is_empty() && !matches!( display_options.display_mode, DisplayMode::SideBySideShowBoth ) { for line in display_single_column( display_path, old_path, file_format, &rhs_colored_lines, Side::Right, display_options, ) { print!("{}", line); } println!(); return; } if rhs_src.is_empty() && !matches!( display_options.display_mode, DisplayMode::SideBySideShowBoth ) { for line in display_single_column( display_path, old_path, file_format, &lhs_colored_lines, Side::Left, display_options, ) { print!("{}", line); } println!(); return; } // TODO: this is largely duplicating the `apply_colors` logic. let (lhs_highlights, rhs_highlights) = if display_options.use_color { highlight_positions( display_options.background_color, display_options.syntax_highlight, file_format, lhs_mps, rhs_mps, ) } else { (DftHashMap::default(), DftHashMap::default()) }; let (lhs_lines_with_novel, rhs_lines_with_novel) = lines_with_novel(lhs_mps, rhs_mps); let mut prev_lhs_line_num = None; let mut prev_rhs_line_num = None; let mut lhs_lines = split_on_newlines(lhs_src).collect::>(); let mut rhs_lines = split_on_newlines(rhs_src).collect::>(); if lhs_lines.last() == Some(&"") && lhs_lines.len() > 1 { lhs_lines.pop(); } if rhs_lines.last() == Some(&"") && rhs_lines.len() > 1 { rhs_lines.pop(); } let matched_lines = all_matched_lines_filled(lhs_mps, rhs_mps, &lhs_lines, &rhs_lines); let mut matched_lines_to_print = &matched_lines[..]; let mut lhs_max_visible_line = 1.into(); let mut rhs_max_visible_line = 1.into(); if let Some(hunk) = hunks.last() { let (start_i, end_i) = matched_lines_indexes_for_hunk( matched_lines_to_print, hunk, display_options.num_context_lines as usize, ); let aligned_lines = &matched_lines_to_print[start_i..end_i]; for (lhs_line_num, rhs_line_num) in aligned_lines.iter().rev() { if let Some(lhs_line_num) = *lhs_line_num { lhs_max_visible_line = max(lhs_max_visible_line, lhs_line_num); } if let Some(rhs_line_num) = *rhs_line_num { rhs_max_visible_line = max(rhs_max_visible_line, rhs_line_num); } if lhs_max_visible_line > 1.into() && rhs_max_visible_line > 1.into() { break; } } } let lhs_max_line_in_file = LineNumber(lhs_lines.len().saturating_sub(1) as u32); let rhs_max_line_in_file = LineNumber(rhs_lines.len().saturating_sub(1) as u32); lhs_max_visible_line = LineNumber(min( lhs_max_visible_line.0 + display_options.num_context_lines, lhs_max_line_in_file.0, )); rhs_max_visible_line = LineNumber(min( rhs_max_visible_line.0 + display_options.num_context_lines, rhs_max_line_in_file.0, )); let source_dims = SourceDimensions::new( display_options.terminal_width, lhs_max_visible_line, rhs_max_visible_line, lhs_max_line_in_file, rhs_max_line_in_file, lhs_content_max_width, rhs_content_max_width, ); for (i, hunk) in hunks.iter().enumerate() { println!( "{}", style::header( display_path, old_path, i + 1, hunks.len(), file_format, display_options ) ); let (start_i, end_i) = matched_lines_indexes_for_hunk( matched_lines_to_print, hunk, display_options.num_context_lines as usize, ); let aligned_lines = &matched_lines_to_print[start_i..end_i]; // We iterate through hunks in order, so we know the next hunk // must appear after start_i. This makes // `matched_lines_indexes_for_hunk` faster on later // iterations, and this function is hot on large textual // diffs. matched_lines_to_print = &matched_lines_to_print[start_i..]; let no_lhs_changes = hunk.novel_lhs.is_empty(); let no_rhs_changes = hunk.novel_rhs.is_empty(); let same_lines = aligned_lines.iter().all(|(l, r)| l == r); for (lhs_line_num, rhs_line_num) in aligned_lines { let lhs_line_novel = highlight_as_novel( *lhs_line_num, &lhs_lines, *rhs_line_num, &lhs_lines_with_novel, ); let rhs_line_novel = highlight_as_novel( *rhs_line_num, &rhs_lines, *lhs_line_num, &rhs_lines_with_novel, ); let (display_lhs_line_num, display_rhs_line_num) = display_line_nums( *lhs_line_num, *rhs_line_num, &source_dims, display_options, lhs_line_novel, rhs_line_novel, prev_lhs_line_num, prev_rhs_line_num, ); let show_both = matches!( display_options.display_mode, DisplayMode::SideBySideShowBoth ); if no_lhs_changes && !show_both { match rhs_line_num { Some(rhs_line_num) => { let rhs_line = &rhs_colored_lines[rhs_line_num.as_usize()]; if same_lines { print!("{}{}", display_rhs_line_num, rhs_line); } else { print!( "{}{}{}", display_lhs_line_num, display_rhs_line_num, rhs_line ); } } None => { // We didn't have any changed RHS lines in the // hunk, but we had some contextual lines that // only occurred on the LHS (e.g. extra newlines). println!("{}{}", display_lhs_line_num, display_rhs_line_num); } } } else if no_rhs_changes && !show_both { match lhs_line_num { Some(lhs_line_num) => { let lhs_line = &lhs_colored_lines[lhs_line_num.as_usize()]; if same_lines { print!("{}{}", display_lhs_line_num, lhs_line); } else { print!( "{}{}{}", display_lhs_line_num, display_rhs_line_num, lhs_line ); } } None => { println!("{}{}", display_lhs_line_num, display_rhs_line_num); } } } else { let lhs_line = match lhs_line_num { Some(lhs_line_num) => split_and_apply( lhs_lines[lhs_line_num.as_usize()], source_dims.lhs_content_display_width, display_options.tab_width, lhs_highlights.get(lhs_line_num).unwrap_or(&vec![]), Side::Left, ), None => vec![" ".repeat(source_dims.lhs_content_display_width)], }; let rhs_line = match rhs_line_num { Some(rhs_line_num) => split_and_apply( rhs_lines[rhs_line_num.as_usize()], source_dims.rhs_content_display_width, display_options.tab_width, rhs_highlights.get(rhs_line_num).unwrap_or(&vec![]), Side::Right, ), None => vec!["".into()], }; for (i, (lhs_line, rhs_line)) in zip_pad_shorter(&lhs_line, &rhs_line) .into_iter() .enumerate() { let lhs_line = lhs_line .unwrap_or_else(|| " ".repeat(source_dims.lhs_content_display_width)); let rhs_line = rhs_line.unwrap_or_else(|| "".into()); let lhs_num: String = if i == 0 { display_lhs_line_num.clone() } else { let mut s = format_missing_line_num( lhs_line_num .unwrap_or_else(|| prev_lhs_line_num.unwrap_or_else(|| 10.into())), &source_dims, Side::Left, true, display_options.use_color, ); if let Some(line_num) = lhs_line_num { s = apply_line_number_color( &s, lhs_lines_with_novel.contains(line_num), Side::Left, display_options, ); } s }; let rhs_num: String = if i == 0 { display_rhs_line_num.clone() } else { let mut s = format_missing_line_num( rhs_line_num .unwrap_or_else(|| prev_rhs_line_num.unwrap_or_else(|| 10.into())), &source_dims, Side::Right, true, display_options.use_color, ); if let Some(line_num) = rhs_line_num { s = apply_line_number_color( &s, rhs_lines_with_novel.contains(line_num), Side::Right, display_options, ); } s }; println!("{}{}{}{}{}", lhs_num, lhs_line, SPACER, rhs_num, rhs_line); } } if lhs_line_num.is_some() { prev_lhs_line_num = *lhs_line_num; } if rhs_line_num.is_some() { prev_rhs_line_num = *rhs_line_num; } } println!(); } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; use crate::options::DEFAULT_TERMINAL_WIDTH; use crate::parse::guess_language::Language; use crate::syntax::{AtomKind, MatchKind, TokenKind}; #[test] fn test_width_calculations() { let source_dims = SourceDimensions::new( DEFAULT_TERMINAL_WIDTH, 1.into(), 10.into(), 1.into(), 10.into(), 9999, 9999, ); assert_eq!(source_dims.lhs_line_nums_width, 2); assert_eq!(source_dims.rhs_line_nums_width, 3); } #[test] fn test_format_missing_line_num() { let source_dims = SourceDimensions::new( DEFAULT_TERMINAL_WIDTH, 1.into(), 1.into(), 1.into(), 1.into(), 9999, 9999, ); assert_eq!( format_missing_line_num(0.into(), &source_dims, Side::Left, false, true), ". ".dimmed().to_string() ); assert_eq!( format_missing_line_num(0.into(), &source_dims, Side::Left, false, false), ". ".to_owned() ); } #[test] fn test_format_missing_line_num_at_end() { let source_dims = SourceDimensions::new(80, 1.into(), 1.into(), 1.into(), 1.into(), 9999, 9999); assert_eq!( format_missing_line_num(1.into(), &source_dims, Side::Left, false, true), " ".dimmed().to_string() ); assert_eq!( format_missing_line_num(1.into(), &source_dims, Side::Left, false, false), " ".to_owned() ); } #[test] fn test_display_single_column() { // Basic smoke test. let res_lines = display_single_column( "foo.py", None, &FileFormat::SupportedLanguage(Language::Python), &["print(123)\n".to_owned()], Side::Right, &DisplayOptions::default(), ); let res = res_lines.join(""); assert!(res.len() > 10); } #[test] fn test_display_hunks() { // Simulate diffing: // // Old: // foo // // New: // bar let lhs_mps = [MatchedPos { kind: MatchKind::Novel { highlight: TokenKind::Atom(AtomKind::Normal), }, pos: SingleLineSpan { line: 0.into(), start_col: 0, end_col: 3, }, }]; let rhs_mps = [MatchedPos { kind: MatchKind::Novel { highlight: TokenKind::Atom(AtomKind::Normal), }, pos: SingleLineSpan { line: 0.into(), start_col: 0, end_col: 3, }, }]; let mut novel_lhs = DftHashSet::default(); novel_lhs.insert(0.into()); let mut novel_rhs = DftHashSet::default(); novel_rhs.insert(0.into()); let hunks = [Hunk { novel_lhs, novel_rhs, lines: vec![(Some(0.into()), Some(0.into()))], }]; // Simple smoke test. print( &hunks, &DisplayOptions::default(), "foo-new.el", None, &FileFormat::SupportedLanguage(Language::EmacsLisp), "foo", "bar", &lhs_mps, &rhs_mps, ); } }