diff --git a/src/config.rs b/src/config.rs index af9ade09c..5858afe56 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,11 +145,11 @@ pub struct Config { impl Config { pub fn get_style(&self, state: &State) -> &Style { match state { - State::HunkMinus(_) => &self.minus_style, - State::HunkPlus(_) => &self.plus_style, + State::HunkMinus(_, _) => &self.minus_style, + State::HunkPlus(_, _) => &self.plus_style, State::CommitMeta => &self.commit_style, - State::DiffHeader => &self.file_style, - State::HunkHeader(_, _) => &self.hunk_header_style, + State::DiffHeader(_) => &self.file_style, + State::HunkHeader(_, _, _) => &self.hunk_header_style, State::SubmoduleLog => &self.file_style, _ => delta_unreachable("Unreachable code reached in get_style."), } diff --git a/src/delta.rs b/src/delta.rs index bbbce5198..3f1a79d4c 100644 --- a/src/delta.rs +++ b/src/delta.rs @@ -14,17 +14,17 @@ use crate::style::DecorationStyle; #[derive(Clone, Debug, PartialEq)] pub enum State { - CommitMeta, // In commit metadata section - DiffHeader, // In diff header section, between (possible) commit metadata and first hunk - HunkHeader(String, String), // In hunk metadata line (line, raw_line) - HunkZero, // In hunk; unchanged line - HunkMinus(Option), // In hunk; removed line (raw_line) - HunkPlus(Option), // In hunk; added line (raw_line) - SubmoduleLog, // In a submodule section, with gitconfig diff.submodule = log + CommitMeta, // In commit metadata section + DiffHeader(DiffType), // In diff metadata section, between (possible) commit metadata and first hunk + HunkHeader(DiffType, String, String), // In hunk metadata line (line, raw_line) + HunkZero(Option), // In hunk; unchanged line (prefix) + HunkMinus(Option, Option), // In hunk; removed line (prefix, raw_line) + HunkPlus(Option, Option), // In hunk; added line (prefix, raw_line) + SubmoduleLog, // In a submodule section, with gitconfig diff.submodule = log SubmoduleShort(String), // In a submodule section, with gitconfig diff.submodule = short Blame(String, Option), // In a line of `git blame` output (commit, repeat_blame_line). - GitShowFile, // In a line of `git show $revision:./path/to/file.ext` output - Grep, // In a line of `git grep` output + GitShowFile, // In a line of `git show $revision:./path/to/file.ext` output + Grep, // In a line of `git grep` output Unknown, // The following elements are created when a line is wrapped to display it: HunkZeroWrapped, // Wrapped unchanged line @@ -32,6 +32,12 @@ pub enum State { HunkPlusWrapped, // Wrapped added line } +#[derive(Clone, Debug, PartialEq)] +pub enum DiffType { + Unified, + Combined(usize), // number of parent commits: https://git-scm.com/docs/git-diff#_combined_diff_format +} + #[derive(Debug, PartialEq)] pub enum Source { GitDiff, // Coming from a `git diff` command @@ -171,7 +177,9 @@ impl<'a> StateMachine<'a> { /// Skip file metadata lines unless a raw diff style has been requested. pub fn should_skip_line(&self) -> bool { - self.state == State::DiffHeader && self.should_handle() && !self.config.color_only + matches!(self.state, State::DiffHeader(_)) + && self.should_handle() + && !self.config.color_only } /// Emit unchanged any line that delta does not handle. @@ -211,7 +219,11 @@ pub fn format_raw_line<'a>(line: &'a str, config: &Config) -> Cow<'a, str> { /// * git diff /// * diff -u fn detect_source(line: &str) -> Source { - if line.starts_with("commit ") || line.starts_with("diff --git ") { + if line.starts_with("commit ") + || line.starts_with("diff --git ") + || line.starts_with("diff --cc ") + || line.starts_with("diff --combined ") + { Source::GitDiff } else if line.starts_with("diff -u") || line.starts_with("diff -ru") diff --git a/src/features/line_numbers.rs b/src/features/line_numbers.rs index 0d366f8ae..59218f75c 100644 --- a/src/features/line_numbers.rs +++ b/src/features/line_numbers.rs @@ -76,18 +76,18 @@ pub fn linenumbers_and_styles<'a>( config.line_numbers_style_minusplus[Plus], ); let ((minus_number, plus_number), (minus_style, plus_style)) = match state { - State::HunkMinus(_) => { + State::HunkMinus(_, _) => { line_numbers_data.line_number[Left] += increment as usize; ((Some(nr_left), None), (minus_style, plus_style)) } State::HunkMinusWrapped => ((None, None), (minus_style, plus_style)), - State::HunkZero => { + State::HunkZero(_) => { line_numbers_data.line_number[Left] += increment as usize; line_numbers_data.line_number[Right] += increment as usize; ((Some(nr_left), Some(nr_right)), (zero_style, zero_style)) } State::HunkZeroWrapped => ((None, None), (zero_style, zero_style)), - State::HunkPlus(_) => { + State::HunkPlus(_, _) => { line_numbers_data.line_number[Right] += increment as usize; ((None, Some(nr_right)), (minus_style, plus_style)) } diff --git a/src/features/side_by_side.rs b/src/features/side_by_side.rs index f4960d762..85b066cba 100644 --- a/src/features/side_by_side.rs +++ b/src/features/side_by_side.rs @@ -180,7 +180,7 @@ pub fn paint_minus_and_plus_lines_side_by_side( &lines_have_homolog[Left], match minus_line_index { Some(i) => &line_states[Left][i], - None => &State::HunkMinus(None), + None => &State::HunkMinus(None, None), }, &mut Some(line_numbers_data), bg_should_fill[Left], @@ -201,7 +201,7 @@ pub fn paint_minus_and_plus_lines_side_by_side( &lines_have_homolog[Right], match plus_line_index { Some(i) => &line_states[Right][i], - None => &State::HunkPlus(None), + None => &State::HunkPlus(None, None), }, &mut Some(line_numbers_data), bg_should_fill[Right], @@ -222,7 +222,7 @@ pub fn paint_zero_lines_side_by_side<'a>( painted_prefix: Option, background_color_extends_to_terminal_width: BgShouldFill, ) { - let states = vec![State::HunkZero]; + let states = vec![State::HunkZero(None)]; let (states, syntax_style_sections, diff_style_sections) = wrap_zero_block( config, @@ -418,8 +418,8 @@ fn paint_minus_or_plus_panel_line<'a>( ) } else { let opposite_state = match state { - State::HunkMinus(x) => State::HunkPlus(x.clone()), - State::HunkPlus(x) => State::HunkMinus(x.clone()), + State::HunkMinus(_, s) => State::HunkPlus(None, s.clone()), + State::HunkPlus(_, s) => State::HunkMinus(None, s.clone()), _ => unreachable!(), }; ( @@ -470,17 +470,17 @@ fn pad_panel_line_to_width<'a>( // to form the other half of the line, then don't emit the empty line marker. if panel_line_is_empty && line_index.is_some() { match state { - State::HunkMinus(_) => Painter::mark_empty_line( + State::HunkMinus(_, _) => Painter::mark_empty_line( &config.minus_empty_line_marker_style, panel_line, Some(" "), ), - State::HunkPlus(_) => Painter::mark_empty_line( + State::HunkPlus(_, _) => Painter::mark_empty_line( &config.plus_empty_line_marker_style, panel_line, Some(" "), ), - State::HunkZero => {} + State::HunkZero(_) => {} _ => unreachable!(), }; }; diff --git a/src/handlers/diff_header.rs b/src/handlers/diff_header.rs index e28887976..1a7033932 100644 --- a/src/handlers/diff_header.rs +++ b/src/handlers/diff_header.rs @@ -5,7 +5,7 @@ use unicode_segmentation::UnicodeSegmentation; use super::draw; use crate::config::Config; -use crate::delta::{Source, State, StateMachine}; +use crate::delta::{DiffType, Source, State, StateMachine}; use crate::features; use crate::paint::Painter; @@ -24,7 +24,7 @@ pub enum FileEvent { impl<'a> StateMachine<'a> { #[inline] fn test_diff_header_minus_line(&self) -> bool { - (self.state == State::DiffHeader || self.source == Source::DiffUnified) + (matches!(self.state, State::DiffHeader(_)) || self.source == Source::DiffUnified) && (self.line.starts_with("--- ") || self.line.starts_with("rename from ") || self.line.starts_with("copy from ") @@ -57,7 +57,7 @@ impl<'a> StateMachine<'a> { self.minus_file_event = file_event; if self.source == Source::DiffUnified { - self.state = State::DiffHeader; + self.state = State::DiffHeader(DiffType::Unified); self.painter .set_syntax(get_file_extension_from_marker_line(&self.line)); } else { @@ -85,7 +85,7 @@ impl<'a> StateMachine<'a> { #[inline] fn test_diff_header_plus_line(&self) -> bool { - (self.state == State::DiffHeader || self.source == Source::DiffUnified) + (matches!(self.state, State::DiffHeader(_)) || self.source == Source::DiffUnified) && (self.line.starts_with("+++ ") || self.line.starts_with("rename to ") || self.line.starts_with("copy to ") diff --git a/src/handlers/diff_header_diff.rs b/src/handlers/diff_header_diff.rs index 78ec2a98c..869e40ef1 100644 --- a/src/handlers/diff_header_diff.rs +++ b/src/handlers/diff_header_diff.rs @@ -1,4 +1,4 @@ -use crate::delta::{State, StateMachine}; +use crate::delta::{DiffType, State, StateMachine}; impl<'a> StateMachine<'a> { #[inline] @@ -12,7 +12,12 @@ impl<'a> StateMachine<'a> { return Ok(false); } self.painter.paint_buffered_minus_and_plus_lines(); - self.state = State::DiffHeader; + self.state = + if self.line.starts_with("diff --cc ") || self.line.starts_with("diff --combined ") { + State::DiffHeader(DiffType::Combined(2)) // We will confirm the number of parents when we see the hunk header + } else { + State::DiffHeader(DiffType::Unified) + }; self.handled_diff_header_header_line_file_pair = None; self.diff_line = self.line.clone(); if !self.should_skip_line() { diff --git a/src/handlers/diff_header_misc.rs b/src/handlers/diff_header_misc.rs index f112d46b9..eb0833def 100644 --- a/src/handlers/diff_header_misc.rs +++ b/src/handlers/diff_header_misc.rs @@ -1,4 +1,4 @@ -use crate::delta::{Source, State, StateMachine}; +use crate::delta::{DiffType, Source, State, StateMachine}; impl<'a> StateMachine<'a> { #[inline] @@ -11,6 +11,9 @@ impl<'a> StateMachine<'a> { if !self.test_diff_header_misc_cases() { return Ok(false); } - self.handle_additional_cases(State::DiffHeader) + self.handle_additional_cases(match self.state { + State::DiffHeader(_) => self.state.clone(), + _ => State::DiffHeader(DiffType::Unified), + }) } } diff --git a/src/handlers/hunk.rs b/src/handlers/hunk.rs index 75e0bb6fd..93ac70ca6 100644 --- a/src/handlers/hunk.rs +++ b/src/handlers/hunk.rs @@ -1,7 +1,10 @@ +use std::cmp::min; + use lazy_static::lazy_static; use crate::cli; -use crate::delta::{State, StateMachine}; +use crate::config::delta_unreachable; +use crate::delta::{DiffType, State, StateMachine}; use crate::style; use crate::utils::process::{self, CallingProcess}; use unicode_segmentation::UnicodeSegmentation; @@ -26,7 +29,10 @@ impl<'a> StateMachine<'a> { fn test_hunk_line(&self) -> bool { matches!( self.state, - State::HunkHeader(_, _) | State::HunkZero | State::HunkMinus(_) | State::HunkPlus(_) + State::HunkHeader(_, _, _) + | State::HunkZero(_) + | State::HunkMinus(_, _) + | State::HunkPlus(_, _) ) && !&*IS_WORD_DIFF } @@ -37,6 +43,7 @@ impl<'a> StateMachine<'a> { // highlighting according to inferred edit operations. In the case of // an unchanged line, we paint it immediately. pub fn handle_hunk_line(&mut self) -> std::io::Result { + use State::*; // A true hunk line should start with one of: '+', '-', ' '. However, handle_hunk_line // handles all lines until the state transitions away from the hunk states. if !self.test_hunk_line() { @@ -50,16 +57,17 @@ impl<'a> StateMachine<'a> { { self.painter.paint_buffered_minus_and_plus_lines(); } - if let State::HunkHeader(line, raw_line) = &self.state.clone() { + if let State::HunkHeader(_, line, raw_line) = &self.state.clone() { self.emit_hunk_header_line(line, raw_line)?; } - self.state = match self.line.chars().next() { - Some('-') => { - if let State::HunkPlus(_) = self.state { + self.state = match new_line_state(&self.line, &self.state) { + Some(HunkMinus(prefix, _)) => { + if let HunkPlus(_, _) = self.state { // We have just entered a new subhunk; process the previous one // and flush the line buffers. self.painter.paint_buffered_minus_and_plus_lines(); } + let line = self.painter.prepare(&self.line, prefix.as_deref()); let state = match self.config.inspect_raw_lines { cli::InspectRawLines::True if style::line_has_style_other_than( @@ -67,16 +75,18 @@ impl<'a> StateMachine<'a> { [*style::GIT_DEFAULT_MINUS_STYLE, self.config.git_minus_style].iter(), ) => { - State::HunkMinus(Some(self.painter.prepare_raw_line(&self.raw_line))) + let raw_line = self + .painter + .prepare_raw_line(&self.raw_line, prefix.as_deref()); + HunkMinus(prefix, Some(raw_line)) } - _ => State::HunkMinus(None), + _ => HunkMinus(prefix, None), }; - self.painter - .minus_lines - .push((self.painter.prepare(&self.line), state.clone())); + self.painter.minus_lines.push((line, state.clone())); state } - Some('+') => { + Some(HunkPlus(prefix, _)) => { + let line = self.painter.prepare(&self.line, prefix.as_deref()); let state = match self.config.inspect_raw_lines { cli::InspectRawLines::True if style::line_has_style_other_than( @@ -84,37 +94,70 @@ impl<'a> StateMachine<'a> { [*style::GIT_DEFAULT_PLUS_STYLE, self.config.git_plus_style].iter(), ) => { - State::HunkPlus(Some(self.painter.prepare_raw_line(&self.raw_line))) + let raw_line = self + .painter + .prepare_raw_line(&self.raw_line, prefix.as_deref()); + HunkPlus(prefix, Some(raw_line)) } - _ => State::HunkPlus(None), + _ => HunkPlus(prefix, None), }; - self.painter - .plus_lines - .push((self.painter.prepare(&self.line), state.clone())); + self.painter.plus_lines.push((line, state.clone())); state } - Some(' ') => { + Some(HunkZero(prefix)) => { + // We are in a zero (unchanged) line, therefore we have just exited a subhunk (a + // sequence of consecutive minus (removed) and/or plus (added) lines). Process that + // subhunk and flush the line buffers. self.painter.paint_buffered_minus_and_plus_lines(); - self.painter.paint_zero_line(&self.line); - State::HunkZero + self.painter.paint_zero_line(&self.line, prefix.clone()); + HunkZero(prefix) } _ => { // The first character here could be e.g. '\' from '\ No newline at end of file'. This // is not a hunk line, but the parser does not have a more accurate state corresponding // to this. - - // We are in a zero (unchanged) line, therefore we have just exited a subhunk (a - // sequence of consecutive minus (removed) and/or plus (added) lines). Process that - // subhunk and flush the line buffers. self.painter.paint_buffered_minus_and_plus_lines(); self.painter .output_buffer .push_str(&self.painter.expand_tabs(self.raw_line.graphemes(true))); self.painter.output_buffer.push('\n'); - State::HunkZero + State::HunkZero(None) } }; self.painter.emit()?; Ok(true) } } + +fn new_line_state(new_line: &str, prev_state: &State) -> Option { + use State::*; + let diff_type = match prev_state { + HunkMinus(None, _) | HunkZero(None) | HunkPlus(None, _) => DiffType::Unified, + HunkHeader(diff_type, _, _) => diff_type.clone(), + HunkMinus(Some(prefix), _) | HunkZero(Some(prefix)) | HunkPlus(Some(prefix), _) => { + DiffType::Combined(prefix.len()) + } + _ => delta_unreachable(&format!("diff_type: unexpected state: {:?}", prev_state)), + }; + + let (prefix_char, prefix) = match diff_type { + DiffType::Unified => (new_line.chars().next(), None), + DiffType::Combined(n_parents) => { + let prefix = &new_line[..min(n_parents, new_line.len())]; + let prefix_char = match prefix.chars().find(|c| c == &'-' || c == &'+') { + Some(c) => Some(c), + None => match prefix.chars().find(|c| c != &' ') { + None => Some(' '), + Some(_) => None, + }, + }; + (prefix_char, Some(prefix.to_string())) + } + }; + match prefix_char { + Some('-') => Some(HunkMinus(prefix, None)), + Some(' ') => Some(HunkZero(prefix)), + Some('+') => Some(HunkPlus(prefix, None)), + _ => None, + } +} diff --git a/src/handlers/hunk_header.rs b/src/handlers/hunk_header.rs index dd851509d..39c223d60 100644 --- a/src/handlers/hunk_header.rs +++ b/src/handlers/hunk_header.rs @@ -26,7 +26,7 @@ use regex::Regex; use super::draw; use crate::config::Config; -use crate::delta::{self, State, StateMachine}; +use crate::delta::{self, DiffType, State, StateMachine}; use crate::paint::{self, BgShouldFill, Painter, StyleSectionSpecifier}; use crate::style::DecorationStyle; @@ -40,7 +40,15 @@ impl<'a> StateMachine<'a> { if !self.test_hunk_header_line() { return Ok(false); } - self.state = State::HunkHeader(self.line.clone(), self.raw_line.clone()); + let diff_type = match &self.state { + State::DiffHeader(DiffType::Combined(_)) => { + // https://git-scm.com/docs/git-diff#_combined_diff_format + let n_parents = self.line.chars().take_while(|c| c == &'@').count() - 1; + DiffType::Combined(n_parents) + } + _ => DiffType::Unified, + }; + self.state = State::HunkHeader(diff_type, self.line.clone(), self.raw_line.clone()); Ok(true) } @@ -251,7 +259,7 @@ fn write_to_output_buffer( painter.syntax_highlight_and_paint_line( &line, StyleSectionSpecifier::Style(config.hunk_header_style), - delta::State::HunkHeader("".to_owned(), "".to_owned()), + delta::State::HunkHeader(DiffType::Unified, "".to_owned(), "".to_owned()), BgShouldFill::No, ); painter.output_buffer.pop(); // trim newline diff --git a/src/handlers/submodule.rs b/src/handlers/submodule.rs index 4350c561a..04e273e92 100644 --- a/src/handlers/submodule.rs +++ b/src/handlers/submodule.rs @@ -18,7 +18,7 @@ impl<'a> StateMachine<'a> { #[inline] fn test_submodule_short_line(&self) -> bool { - matches!(self.state, State::HunkHeader(_, _)) + matches!(self.state, State::HunkHeader(_, _, _)) && self.line.starts_with("-Subproject commit ") || matches!(self.state, State::SubmoduleShort(_)) && self.line.starts_with("+Subproject commit ") @@ -29,7 +29,7 @@ impl<'a> StateMachine<'a> { return Ok(false); } if let Some(commit) = get_submodule_short_commit(&self.line) { - if let State::HunkHeader(_, _) = self.state { + if let State::HunkHeader(_, _, _) = self.state { self.state = State::SubmoduleShort(commit.to_owned()); } else if let State::SubmoduleShort(minus_commit) = &self.state { self.painter.emit()?; diff --git a/src/paint.rs b/src/paint.rs index 5821ee927..0ef92dbdc 100644 --- a/src/paint.rs +++ b/src/paint.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::io::Write; +use ansi_term::ANSIString; use itertools::Itertools; use syntect::easy::HighlightLines; use syntect::highlighting::Style as SyntectStyle; @@ -122,26 +123,33 @@ impl<'p> Painter<'p> { // Terminating with newline character is necessary for many of the sublime syntax definitions to // highlight correctly. // See https://docs.rs/syntect/3.2.0/syntect/parsing/struct.SyntaxSetBuilder.html#method.add_from_folder - pub fn prepare(&self, line: &str) -> String { + pub fn prepare(&self, line: &str, prefix: Option<&str>) -> String { if !line.is_empty() { let mut line = line.graphemes(true); - // The first column contains a -/+/space character, added by git. We remove it now so that - // it is not present during syntax highlighting or wrapping. If --keep-plus-minus-markers is - // in effect this character is re-inserted in Painter::paint_line. - line.next(); + // The initial columns contain -/+/space characters, added by git. Remove them now so + // they are not present during syntax highlighting or wrapping. If + // --keep-plus-minus-markers is in effect this prefix is re-inserted in + // Painter::paint_line. + let prefix_length = prefix.map(|s| s.len()).unwrap_or(1); + for _ in 0..prefix_length { + line.next(); + } format!("{}\n", self.expand_tabs(line)) } else { "\n".to_string() } } - // Remove initial -/+ character, expand tabs as spaces, retaining ANSI sequences. Terminate with + // Remove initial -/+ characters, expand tabs as spaces, retaining ANSI sequences. Terminate with // newline character. - pub fn prepare_raw_line(&self, raw_line: &str) -> String { + pub fn prepare_raw_line(&self, raw_line: &str, prefix: Option<&str>) -> String { format!( "{}\n", - ansi::ansi_preserving_slice(&self.expand_tabs(raw_line.graphemes(true)), 1), + ansi::ansi_preserving_slice( + &self.expand_tabs(raw_line.graphemes(true)), + prefix.map(|s| s.len()).unwrap_or(1) + ), ) } @@ -207,11 +215,6 @@ impl<'p> Painter<'p> { &mut self.output_buffer, self.config, &mut self.line_numbers_data.as_mut(), - if self.config.keep_plus_minus_markers { - Some(self.config.minus_style.paint("-")) - } else { - None - }, Some(self.config.minus_empty_line_marker_style), BgShouldFill::default(), ); @@ -225,11 +228,6 @@ impl<'p> Painter<'p> { &mut self.output_buffer, self.config, &mut self.line_numbers_data.as_mut(), - if self.config.keep_plus_minus_markers { - Some(self.config.plus_style.paint("+")) - } else { - None - }, Some(self.config.plus_empty_line_marker_style), BgShouldFill::default(), ); @@ -239,16 +237,10 @@ impl<'p> Painter<'p> { self.plus_lines.clear(); } - pub fn paint_zero_line(&mut self, line: &str) { - let state = State::HunkZero; - let painted_prefix = if self.config.keep_plus_minus_markers && !line.is_empty() { - // A zero line here still contains the " " prefix, so use it. - Some(self.config.zero_style.paint(&line[..1])) - } else { - None - }; - - let lines = vec![(self.prepare(line), state)]; + pub fn paint_zero_line(&mut self, line: &str, prefix: Option) { + let line = self.prepare(line, prefix.as_deref()); + let state = State::HunkZero(prefix); + let lines = vec![(line, state.clone())]; let syntax_style_sections = Painter::get_syntax_style_sections_for_lines( &lines, self.highlighter.as_mut(), @@ -265,7 +257,7 @@ impl<'p> Painter<'p> { &mut self.output_buffer, self.config, &mut self.line_numbers_data.as_mut(), - painted_prefix, + painted_prefix(state, self.config), BgShouldFill::With(BgFillMethod::Spaces), ); } else { @@ -277,7 +269,6 @@ impl<'p> Painter<'p> { &mut self.output_buffer, self.config, &mut self.line_numbers_data.as_mut(), - painted_prefix, None, BgShouldFill::With(BgFillMethod::Spaces), ); @@ -295,7 +286,6 @@ impl<'p> Painter<'p> { output_buffer: &mut String, config: &config::Config, line_numbers_data: &mut Option<&mut line_numbers::LineNumbersData>, - painted_prefix: Option, empty_line_style: Option