commit 32b79c1a635817f190f3125a813971b966726c51 Author: upstream Date: Wed Sep 2 11:06:59 2026 +0200 Upstream Wilfred/difftastic state of the touched files before 519f6a9c21 diff --git a/src/display/side_by_side.rs b/src/display/side_by_side.rs new file mode 100644 index 0000000..e1ba2c2 --- /dev/null +++ b/src/display/side_by_side.rs @@ -0,0 +1,916 @@ +//! 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, + ); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..de8c88f --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,293 @@ +use std::process::Command; + +use assert_cmd::prelude::*; +use predicates::prelude::*; + +fn find_runner() -> Option { + for (key, value) in std::env::vars() { + if key.starts_with("CARGO_TARGET_") && key.ends_with("_RUNNER") && !value.is_empty() { + return Some(value); + } + } + None +} + +// Sample code from +// https://github.com/assert-rs/assert_cmd/issues/139, supports +// cross-compiled binaries. +fn get_base_command() -> Command { + let mut cmd; + let path = assert_cmd::cargo_bin!("difft"); + if let Some(runner) = find_runner() { + let mut runner = runner.split_whitespace(); + cmd = Command::new(runner.next().unwrap()); + for arg in runner { + cmd.arg(arg); + } + cmd.arg(path); + } else { + cmd = Command::new(path); + } + cmd +} + +#[test] +fn no_such_files() { + let mut cmd = get_base_command(); + + cmd.arg("no_such_file").arg("no_such_file_either"); + cmd.assert().failure().code(2); +} + +#[test] +fn inline() { + let mut cmd = get_base_command(); + + cmd.arg("--display=inline") + .arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + cmd.assert().success(); +} + +#[test] +fn binary_changed() { + let mut cmd = get_base_command(); + + cmd.arg("--display=inline") + .arg("img/logo.png") + .arg("/dev/null"); + + let predicate_fn = predicate::str::contains("Binary file removed"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn binary_override() { + let mut cmd = get_base_command(); + + cmd.arg("--override-binary=*.js") + .arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + let predicate_fn = predicate::str::contains("Binary file modified"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn has_changes_default_exit_code() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + cmd.assert().success(); +} + +#[test] +fn has_changes_requested_exit_code() { + let mut cmd = get_base_command(); + + cmd.arg("--exit-code") + .arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + cmd.assert().failure().code(1); +} + +#[test] +fn ignore_comments() { + let mut cmd = get_base_command(); + + cmd.arg("--ignore-comments") + .arg("sample_files/comma_and_comment_1.js") + .arg("sample_files/comma_and_comment_2.js"); + + let predicate_fn = predicate::str::contains("No syntactic changes"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn check_only() { + let mut cmd = get_base_command(); + + cmd.arg("--check-only") + .arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + + let predicate_fn = predicate::str::contains("Has syntactic changes"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn check_only_text_file() { + let mut cmd = get_base_command(); + + cmd.arg("--check-only") + .arg("sample_files/text_1.txt") + .arg("sample_files/text_2.txt"); + + let predicate_fn = predicate::str::contains("Has changes"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn text_changes_at_end_doesnt_crash() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/cli_tests/changes_at_end_1.txt") + .arg("sample_files/cli_tests/changes_at_end_2.txt"); + + cmd.assert().success(); +} + +#[test] +fn makefile_text_as_atom() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/cli_tests/makefile_1.mk") + .arg("sample_files/cli_tests/makefile_2.mk"); + + let predicate_fn = predicate::str::contains("CCFLAGS"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn yaml_parse_errors() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/cli_tests/bad_yaml_1.yml") + .arg("sample_files/cli_tests/bad_yaml_2.yml"); + + let predicate_fn = predicate::str::contains("exceeded DFT_PARSE_ERROR_LIMIT"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn list_languages() { + let mut cmd = get_base_command(); + + cmd.arg("--list-languages"); + + let predicate_fn = predicate::str::contains("TOML"); + cmd.assert().stdout(predicate_fn); + + let predicate_fn = predicate::str::contains("*.toml"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn test_mime_type_false_positive() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/cli_tests/x_mod_false_positive.js") + .arg("sample_files/cli_tests/empty.js"); + + let predicate_fn = predicate::str::contains("JavaScript"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn slightly_invalid_utf8() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/cli_tests/MainWindowViewModel.cs") + .arg("/dev/null"); + + let predicate_fn = predicate::str::contains("using"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn directory_arguments() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/dir_1").arg("sample_files/dir_2"); + + let predicate_fn = + predicate::str::contains("only_in_1.c").and(predicate::str::contains("only_in_2.rs")); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn git_style_arguments_rename() { + let mut cmd = get_base_command(); + + cmd.arg("elisp_oldname.el") + .arg("sample_files/elisp_1.el") + .arg("lhs_hash_placeholder") + .arg("lhs_mode_placeholder") + .arg("sample_files/elisp_2.el") + .arg("rhs_hash_placeholder") + .arg("rhs_mode_placeholder") + .arg("elisp_newname.el") + .arg("similarity_placeholder"); + let predicate_fn = predicate::str::contains("Renamed"); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn git_style_arguments_new_file() { + let mut cmd = get_base_command(); + + cmd.arg("simple.txt") + .arg("/dev/null") + .arg(".") + .arg(".") + .arg("sample_files/simple_1.txt") + .arg("abcdef1234") + .arg("100644"); + let predicate_fn = predicate::str::contains("File permissions changed").not(); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn drop_different_path_starts() { + let mut cmd = get_base_command(); + + cmd.arg("sample_files/dir_1/clojure.clj") + .arg("sample_files/dir_2/clojure.clj"); + let predicate_fn = predicate::str::contains("dir_2/clojure.clj").not(); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn dump_tree_sitter() { + let mut cmd = get_base_command(); + + cmd.arg("--dump-ts") + .arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + cmd.assert().success(); +} + +#[test] +fn dump_syntax() { + let mut cmd = get_base_command(); + + cmd.arg("--dump-syntax") + .arg("sample_files/simple_1.js") + .arg("sample_files/simple_2.js"); + cmd.assert().success(); +} + +#[test] +fn walk_hidden_items() { + let mut cmd = get_base_command(); + + cmd.args(["sample_files/hidden_1", "sample_files/hidden_2"]); + + let predicate_fn = + predicate::str::contains(format!(".hidden{}doc.txt", std::path::MAIN_SEPARATOR)) + .and(predicate::str::contains(".hidden.txt")) + .and(predicate::str::contains("before")) + .and(predicate::str::contains("after")); + cmd.assert().stdout(predicate_fn); +} + +#[test] +fn git_unmerged_files() { + let mut cmd = get_base_command(); + + cmd.args(["sample_files/simple_1.js"]); + cmd.env("GIT_EXEC_PATH", "/usr/lib/git-core"); + + let predicate_fn = predicate::str::contains("Unmerged path"); + cmd.assert().stdout(predicate_fn); +}