From 4e58aab1e76c68a899a21d3c1d86eef82fae35b3 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Mon, 15 Nov 2021 17:24:15 -0500 Subject: [PATCH 1/4] Fix test of parent process when run under cargo-tarpaulin --- src/utils.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils.rs b/src/utils.rs index 6f997d932..844f9f31c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -295,6 +295,10 @@ mod tests { assert!(parent.is_some()); // Tests that caller is something like "cargo test" - assert!(parent.unwrap().cmd().iter().any(|a| a == "test")); + assert!(parent + .unwrap() + .cmd() + .iter() + .any(|a| a == "test" || a == "tarpaulin")); } } From e8637bc0ee0eedd31cfffd421b4e77c98871bec8 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 11 Nov 2021 10:50:43 -0500 Subject: [PATCH 2/4] Clean up --- src/delta.rs | 2 +- src/parse_style.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/delta.rs b/src/delta.rs index 71b96bffb..8d2b067f5 100644 --- a/src/delta.rs +++ b/src/delta.rs @@ -13,7 +13,7 @@ use crate::style::DecorationStyle; #[derive(Clone, Debug, PartialEq)] pub enum State { - CommitMeta, // In commit metadata section + CommitMeta, // In commit metadata section FileMeta, // In diff metadata section, between (possible) commit metadata and first hunk HunkHeader(String, String), // In hunk metadata line (line, raw_line) HunkZero, // In hunk; unchanged line diff --git a/src/parse_style.rs b/src/parse_style.rs index 49299fa9a..820cc036d 100644 --- a/src/parse_style.rs +++ b/src/parse_style.rs @@ -327,8 +327,6 @@ fn _extract_special_decoration_attributes( mod tests { use super::*; - use ansi_term; - #[test] fn test_parse_ansi_term_style() { assert_eq!( From 8018d39bfc442773f276328895cea717ba2cdc0d Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 11 Nov 2021 10:49:23 -0500 Subject: [PATCH 3/4] Support precision specifier in run-time format strings --- src/features/line_numbers.rs | 31 +++++++++++++++++++--- src/format.rs | 51 ++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/features/line_numbers.rs b/src/features/line_numbers.rs index 0cc4ebfb8..7082d05db 100644 --- a/src/features/line_numbers.rs +++ b/src/features/line_numbers.rs @@ -266,14 +266,22 @@ fn format_and_paint_line_number_field<'a>( .as_ref() .unwrap_or(&Align::Center); match placeholder.placeholder { - Some(Placeholder::NumberMinus) => ansi_strings.push(styles[Minus].paint( - format_line_number(line_numbers[Minus], alignment_spec, width, None, config), - )), + Some(Placeholder::NumberMinus) => { + ansi_strings.push(styles[Minus].paint(format_line_number( + line_numbers[Minus], + alignment_spec, + width, + placeholder.precision, + None, + config, + ))) + } Some(Placeholder::NumberPlus) => { ansi_strings.push(styles[Plus].paint(format_line_number( line_numbers[Plus], alignment_spec, width, + placeholder.precision, Some(plus_file), config, ))) @@ -292,10 +300,11 @@ fn format_line_number( line_number: Option, alignment: &Align, width: usize, + precision: Option, plus_file: Option<&str>, config: &config::Config, ) -> String { - let pad = |n| format::pad(n, width, alignment); + let pad = |n| format::pad(n, width, alignment, precision); match (line_number, config.hyperlinks, plus_file) { (None, _, _) => pad(""), (Some(n), true, Some(file)) => { @@ -332,6 +341,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberMinus), alignment_spec: None, width: None, + precision: None, suffix: "".into(), prefix_len: 0, suffix_len: 0, @@ -348,6 +358,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberPlus), alignment_spec: None, width: Some(4), + precision: None, suffix: "".into(), prefix_len: 0, suffix_len: 0, @@ -364,6 +375,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberPlus), alignment_spec: Some(Align::Right), width: Some(4), + precision: None, suffix: "".into(), prefix_len: 0, suffix_len: 0, @@ -380,6 +392,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberPlus), alignment_spec: Some(Align::Right), width: Some(4), + precision: None, suffix: "".into(), prefix_len: 0, suffix_len: 0, @@ -396,6 +409,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberPlus), alignment_spec: Some(Align::Right), width: Some(4), + precision: None, suffix: "@@".into(), prefix_len: 2, suffix_len: 2, @@ -413,6 +427,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberMinus), alignment_spec: Some(Align::Left), width: Some(3), + precision: None, suffix: "@@---{np:_>4}**".into(), prefix_len: 2, suffix_len: 15, @@ -422,6 +437,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberPlus), alignment_spec: Some(Align::Right), width: Some(4), + precision: None, suffix: "**".into(), prefix_len: 5, suffix_len: 2, @@ -439,6 +455,7 @@ pub mod tests { placeholder: None, alignment_spec: None, width: None, + precision: None, suffix: "__@@---**".into(), prefix_len: 0, suffix_len: 9, @@ -455,12 +472,14 @@ pub mod tests { placeholder: Some(Placeholder::NumberMinus), alignment_spec: Some(Align::Left), width: Some(4), + precision: None, suffix: "|".into(), prefix_len: 2, suffix_len: 1, }] ); } + #[test] fn test_line_number_format_odd_width_two() { assert_eq!( @@ -475,6 +494,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberMinus), alignment_spec: Some(Align::Left), width: Some(4), + precision: None, suffix: "+{np:<4}|".into(), prefix_len: 2, suffix_len: 9, @@ -484,6 +504,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberPlus), alignment_spec: Some(Align::Left), width: Some(4), + precision: None, suffix: "|".into(), prefix_len: 1, suffix_len: 1, @@ -500,6 +521,7 @@ pub mod tests { placeholder: None, alignment_spec: None, width: None, + precision: None, suffix: "|++|".into(), prefix_len: 1, suffix_len: 4, @@ -522,6 +544,7 @@ pub mod tests { placeholder: Some(Placeholder::NumberMinus), alignment_spec: None, width: None, + precision: None, suffix: long.into(), suffix_len: long.len(), },] diff --git a/src/format.rs b/src/format.rs index bbc6e9f5a..ab0e856fc 100644 --- a/src/format.rs +++ b/src/format.rs @@ -55,6 +55,7 @@ pub struct FormatStringPlaceholderData<'a> { pub placeholder: Option>, pub alignment_spec: Option, pub width: Option, + pub precision: Option, pub suffix: SmolStr, pub suffix_len: usize, } @@ -90,6 +91,9 @@ pub fn make_placeholder_regex(labels: &[&str]) -> Regex { ([<^>]) # 3: Alignment spec )? # (\d+) # 4: Width + (?: # Start optional precision (non-capturing) + \.(\d+) # 5: Precision + )? # )? # \}} ", @@ -134,6 +138,11 @@ pub fn parse_line_number_format<'a>( .parse() .unwrap_or_else(|_| panic!("Invalid width in format string: {}", format_string)) }), + precision: captures.get(5).map(|m| { + m.as_str().parse().unwrap_or_else(|_| { + panic!("Invalid precision in format string: {}", format_string) + }) + }), suffix, suffix_len, }); @@ -156,10 +165,42 @@ pub fn parse_line_number_format<'a>( format_data } -pub fn pad(s: &str, width: usize, alignment: &Align) -> String { - match alignment { - Align::Left => format!("{0:<1$}", s, width), - Align::Center => format!("{0:^1$}", s, width), - Align::Right => format!("{0:>1$}", s, width), +// Note that in this case of a string `s`, `precision` means "max width". +// See https://doc.rust-lang.org/std/fmt/index.html +pub fn pad(s: &str, width: usize, alignment: &Align, precision: Option) -> String { + match precision { + None => match alignment { + Align::Left => format!("{0:<1$}", s, width), + Align::Center => format!("{0:^1$}", s, width), + Align::Right => format!("{0:>1$}", s, width), + }, + Some(precision) => match alignment { + Align::Left => format!("{0:<1$.2$}", s, width, precision), + Align::Center => format!("{0:^1$.2$}", s, width, precision), + Align::Right => format!("{0:>1$.2$}", s, width, precision), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_placeholder_regex() { + let regex = make_placeholder_regex(&["placeholder"]); + assert_eq!( + parse_line_number_format("prefix {placeholder:<15.14} suffix", ®ex, false), + vec![FormatStringPlaceholderData { + prefix: "prefix ".into(), + placeholder: Some(Placeholder::Str("placeholder")), + alignment_spec: Some(Align::Left), + width: Some(15), + precision: Some(14), + suffix: " suffix".into(), + prefix_len: 7, + suffix_len: 7, + }] + ); } } From 95f62d2738c9279a37b962475564077b720dcc8b Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Thu, 11 Nov 2021 10:50:57 -0500 Subject: [PATCH 4/4] Handle `git blame` output Fixes #426 Partial versions of these changes were previously in master and then reverted multiple times. See #746 0745f853d4bed52aca0b6739ac452d54ff54a153 3aab5d19569fa52ace2d7e6d196a1256990c4e20 --- Cargo.lock | 25 +++ Cargo.toml | 2 + README.md | 14 ++ src/cli.rs | 21 ++ src/color.rs | 6 + src/config.rs | 25 +++ src/delta.rs | 5 + src/handlers/blame.rs | 365 +++++++++++++++++++++++++++++++++ src/handlers/mod.rs | 1 + src/options/set.rs | 3 + src/paint.rs | 2 + src/style.rs | 16 ++ src/subcommands/show_config.rs | 11 +- 13 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/handlers/blame.rs diff --git a/Cargo.lock b/Cargo.lock index 65955aeba..0c081ac2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,8 +145,20 @@ version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" dependencies = [ + "libc", "num-integer", "num-traits", + "time", + "winapi", +] + +[[package]] +name = "chrono-humanize" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eddc119501d583fd930cb92144e605f44e0252c38dd89d9247fffa1993375cb" +dependencies = [ + "chrono", ] [[package]] @@ -332,6 +344,8 @@ dependencies = [ "bitflags", "box_drawing", "bytelines", + "chrono", + "chrono-humanize", "console", "ctrlc", "dirs-next", @@ -933,6 +947,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "tinyvec" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1f0d7c7de..e7181ddcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ name = "delta" path = "src/main.rs" [dependencies] +chrono = "0.4.19" +chrono-humanize = "0.2.1" ansi_colours = "1.0.4" ansi_term = "0.12.1" atty = "0.2.14" diff --git a/README.md b/README.md index e2f8f8a91..ff16c1854 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ diff = delta show = delta log = delta + blame = delta reflog = delta [interactive] @@ -63,11 +64,13 @@ Code evolves, and we all spend time studying diffs. Delta aims to make this both - [Choosing colors (styles)](#choosing-colors-styles) - [Line numbers](#line-numbers) - [Side-by-side view](#side-by-side-view) + - [git blame](#git-blame) - ["Features": named groups of settings](#features-named-groups-of-settings) - [Custom themes](#custom-themes) - [diff-highlight and diff-so-fancy emulation](#diff-highlight-and-diff-so-fancy-emulation) - [--color-moved support](#--color-moved-support) - [Navigation keybindings for large diffs](#navigation-keybindings-for-large-diffs) + - [Git blame](#git-blame-1) - [24 bit color (truecolor)](#24-bit-color-truecolor) - [Using Delta with GNU Screen](#using-delta-with-gnu-screen) - [Using Delta on Windows](#using-delta-on-windows) @@ -152,6 +155,7 @@ Here's what `git show` can look like with git configured to use delta: - `diff-highlight` and `diff-so-fancy` emulation modes - Stylable box/line decorations to draw attention to commit, file and hunk header sections. - Support for Git's `--color-moved` feature. +- Customizable `git blame` with syntax highlighting (`--hyperlinks` formats commits as links to GitHub/GitLab/Bitbucket etc) - Code can be copied directly from the diff (`-/+` markers are removed by default). - `n` and `N` keybindings to move between files in large diffs, and between diffs in `log -p` views (`--navigate`) - Commit hashes can be formatted as terminal [hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) to the GitHub/GitLab/Bitbucket page (`--hyperlinks`). @@ -407,6 +411,10 @@ In contrast, the long replacement line in the right panel overflows by almost an For control over the details of line wrapping, see `--wrap-max-lines`, `--wrap-left-symbol`, `--wrap-right-symbol`, `--wrap-right-percent`, `--wrap-right-prefix-symbol`, `--inline-hint-style`. Line wrapping was implemented by @th1000s. +### git blame + +Set delta as the pager for `blame` in the `[pager]` section of your gitconfig. See the example at the [top of the page](#get-started). + ### "Features": named groups of settings All delta options can go under the `[delta]` section in your git config file. However, you can also use named "features" to keep things organized: these are sections in git config like `[delta "my-feature"]`. Here's an example using two custom features: @@ -493,6 +501,12 @@ In order to support this feature, Delta has to look at the raw colors it receive Use the `navigate` feature to activate navigation keybindings. In this mode, pressing `n` will jump forward to the next file in the diff, and `N` will jump backwards. If you are viewing multiple commits (e.g. via `git log -p`) then navigation will also visit commit boundaries. +### Git blame + +Delta will render `git blame` output in its own way, if you have `pager.blame = delta` set in your `gitconfig`. Example: + +
image
+ ### 24 bit color (truecolor) Delta looks best if your terminal application supports 24 bit colors. See https://github.com/termstandard/colors#readme. For example, on MacOS, iTerm2 supports 24-bit colors but Terminal.app does not. diff --git a/src/cli.rs b/src/cli.rs index 9c35f68b0..9ae6130fd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -420,6 +420,27 @@ pub struct Opt { /// (underline), 'ol' (overline), or the combination 'ul ol'. pub hunk_header_decoration_style: String, + /// Format string for git blame commit metadata. Available placeholders are + /// "{timestamp}", "{author}", and "{commit}". + #[structopt( + long = "blame-format", + default_value = "{timestamp:<15} {author:<15.14} {commit:<8} │ " + )] + pub blame_format: String, + + /// Background colors used for git blame lines (space-separated string). + /// Lines added by the same commit are painted with the same color; colors + /// are recycled as needed. + #[structopt(long = "blame-palette")] + pub blame_palette: Option, + + /// Format of `git blame` timestamp in raw git output received by delta. + #[structopt( + long = "blame-timestamp-format", + default_value = "%Y-%m-%d %H:%M:%S %z" + )] + pub blame_timestamp_format: String, + /// Default language used for syntax highlighting when this cannot be /// inferred from a filename. It will typically make sense to set this in /// per-repository git config (.git/config) diff --git a/src/color.rs b/src/color.rs index dfa86fa7c..8140f9c6b 100644 --- a/src/color.rs +++ b/src/color.rs @@ -162,3 +162,9 @@ const DARK_THEME_PLUS_COLOR_256: Color = Color::Fixed(22); const DARK_THEME_PLUS_EMPH_COLOR: Color = Color::RGB(0x00, 0x60, 0x00); const DARK_THEME_PLUS_EMPH_COLOR_256: Color = Color::Fixed(28); + +// blame + +pub const LIGHT_THEME_BLAME_PALETTE: &[&str] = &["#FFFFFF", "#DDDDDD", "#BBBBBB"]; + +pub const DARK_THEME_BLAME_PALETTE: &[&str] = &["#000000", "#222222", "#444444"]; diff --git a/src/config.rs b/src/config.rs index 770a9eb61..3cba6ba3a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,6 +57,9 @@ fn adapt_wrap_max_lines_argument(arg: String) -> usize { pub struct Config { pub available_terminal_width: usize, pub background_color_extends_to_terminal_width: bool, + pub blame_format: String, + pub blame_palette: Vec, + pub blame_timestamp_format: String, pub color_only: bool, pub commit_regex: Regex, pub commit_style: Style, @@ -213,6 +216,8 @@ impl From for Config { _ => *style::GIT_DEFAULT_PLUS_STYLE, }; + let blame_palette = make_blame_palette(opt.blame_palette, opt.computed.is_light_mode); + let file_added_label = opt.file_added_label; let file_copied_label = opt.file_copied_label; let file_modified_label = opt.file_modified_label; @@ -257,6 +262,9 @@ impl From for Config { background_color_extends_to_terminal_width: opt .computed .background_color_extends_to_terminal_width, + blame_format: opt.blame_format, + blame_palette, + blame_timestamp_format: opt.blame_timestamp_format, commit_style, color_only: opt.color_only, commit_regex, @@ -603,6 +611,23 @@ fn make_commit_file_hunk_header_styles(opt: &cli::Opt) -> (Style, Style, Style, ) } +fn make_blame_palette(blame_palette: Option, is_light_mode: bool) -> Vec { + match (blame_palette, is_light_mode) { + (Some(string), _) => string + .split_whitespace() + .map(|s| s.to_owned()) + .collect::>(), + (None, true) => color::LIGHT_THEME_BLAME_PALETTE + .iter() + .map(|s| s.to_string()) + .collect::>(), + (None, false) => color::DARK_THEME_BLAME_PALETTE + .iter() + .map(|s| s.to_string()) + .collect::>(), + } +} + /// Did the user supply `option` on the command line? pub fn user_supplied_option(option: &str, arg_matches: &clap::ArgMatches) -> bool { arg_matches.occurrences_of(option) > 0 diff --git a/src/delta.rs b/src/delta.rs index 8d2b067f5..fbf45c53d 100644 --- a/src/delta.rs +++ b/src/delta.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::HashMap; use std::io::BufRead; use std::io::Write; @@ -21,6 +22,7 @@ pub enum State { HunkPlus(Option), // In hunk; added line (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). Unknown, // The following elements are created when a line is wrapped to display it: HunkZeroWrapped, // Wrapped unchanged line @@ -67,6 +69,7 @@ pub struct StateMachine<'a> { // avoid emitting the file meta header line twice (#245). pub current_file_pair: Option<(String, String)>, pub handled_file_meta_header_line_file_pair: Option<(String, String)>, + pub blame_commit_colors: HashMap, } pub fn delta(lines: ByteLines, writer: &mut dyn Write, config: &Config) -> std::io::Result<()> @@ -92,6 +95,7 @@ impl<'a> StateMachine<'a> { handled_file_meta_header_line_file_pair: None, painter: Painter::new(writer, config), config, + blame_commit_colors: HashMap::new(), } } @@ -116,6 +120,7 @@ impl<'a> StateMachine<'a> { || self.handle_submodule_log_line()? || self.handle_submodule_short_line()? || self.handle_hunk_line()? + || self.handle_blame_line()? || self.should_skip_line() || self.emit_line_unchanged()?; } diff --git a/src/handlers/blame.rs b/src/handlers/blame.rs new file mode 100644 index 000000000..fad87b4d4 --- /dev/null +++ b/src/handlers/blame.rs @@ -0,0 +1,365 @@ +use chrono::{DateTime, FixedOffset}; +use lazy_static::lazy_static; +use regex::Regex; +use std::borrow::Cow; + +use crate::ansi::measure_text_width; +use crate::color; +use crate::config; +use crate::config::delta_unreachable; +use crate::delta::{self, State, StateMachine}; +use crate::format::{self, Placeholder}; +use crate::paint::BgShouldFill; +use crate::style::Style; +use crate::utils; + +impl<'a> StateMachine<'a> { + /// If this is a line of git blame output then render it accordingly. If + /// this is the first blame line, then set the syntax-highlighter language + /// according to delta.default-language. + pub fn handle_blame_line(&mut self) -> std::io::Result { + // TODO: It should be possible to eliminate some of the .clone()s and + // .to_owned()s. + let mut handled_line = false; + self.painter.emit()?; + let (previous_commit, mut repeat_blame_line, try_parse) = match &self.state { + State::Blame(commit, repeat_blame_line) => { + (Some(commit.as_str()), repeat_blame_line.clone(), true) + } + State::Unknown => (None, None, true), + _ => (None, None, false), + }; + if try_parse { + if let Some(blame) = + parse_git_blame_line(&self.line, &self.config.blame_timestamp_format) + { + let is_repeat = previous_commit == Some(blame.commit); + let color = self.get_color(blame.commit, previous_commit, is_repeat); + let mut style = Style::from_colors(None, color::parse_color(&color, true)); + // TODO: This will often be pointlessly updating a key with the + // value it already has. It might be nicer to do this (and + // compute the style) in get_color(), but as things stand the + // borrow checker won't permit that. + self.blame_commit_colors + .insert(blame.commit.to_owned(), color); + + style.is_syntax_highlighted = true; + + // Construct commit metadata, paint, and emit + let format_data = format::parse_line_number_format( + &self.config.blame_format, + &*BLAME_PLACEHOLDER_REGEX, + false, + ); + let blame_line = match (is_repeat, &repeat_blame_line) { + (false, _) => Cow::from(format_blame_metadata( + &format_data, + &blame, + false, + self.config, + )), + (true, None) => { + repeat_blame_line = Some(format_blame_metadata( + &format_data, + &blame, + true, + self.config, + )); + Cow::from(repeat_blame_line.as_ref().unwrap()) + } + (true, Some(repeat_blame_line)) => Cow::from(repeat_blame_line), + }; + write!(self.painter.writer, "{}", style.paint(blame_line))?; + + // Emit syntax-highlighted code + if matches!(self.state, State::Unknown) { + if let Some(lang) = utils::git_blame_filename_extension() + .as_ref() + .or_else(|| self.config.default_language.as_ref()) + { + self.painter.set_syntax(Some(lang)); + self.painter.set_highlighter(); + } + } + self.state = State::Blame(blame.commit.to_owned(), repeat_blame_line.to_owned()); + self.painter.syntax_highlight_and_paint_line( + &format!("{}\n", blame.code), + style, + self.state.clone(), + BgShouldFill::default(), + ); + handled_line = true + } + } + Ok(handled_line) + } + + fn get_color( + &self, + this_commit: &str, + previous_commit: Option<&str>, + is_repeat: bool, + ) -> String { + // Determine color for this line + let previous_commit_color = match previous_commit { + Some(previous_commit) => self.blame_commit_colors.get(previous_commit), + None => None, + }; + + match ( + self.blame_commit_colors.get(this_commit), + previous_commit_color, + is_repeat, + ) { + (Some(commit_color), Some(previous_commit_color), true) => { + debug_assert!(commit_color == previous_commit_color); + // Repeated commit: assign same color + commit_color.to_owned() + } + (None, Some(previous_commit_color), false) => { + // The commit has no color: assign the next color that differs + // from previous commit. + self.get_next_color(Some(previous_commit_color)) + } + (None, None, false) => { + // The commit has no color, and there is no previous commit: + // Just assign the next color. is_repeat is necessarily false. + self.get_next_color(None) + } + (Some(commit_color), Some(previous_commit_color), false) => { + if commit_color != previous_commit_color { + // Consecutive commits differ without a collision + commit_color.to_owned() + } else { + // Consecutive commits differ; prevent color collision + self.get_next_color(Some(commit_color)) + } + } + (None, _, true) => { + delta_unreachable("is_repeat cannot be true when commit has no color.") + } + (Some(_), None, _) => { + delta_unreachable("There must be a previous commit if the commit has a color.") + } + } + } + + fn get_next_color(&self, other_than_color: Option<&str>) -> String { + let n_commits = self.blame_commit_colors.len(); + let n_colors = self.config.blame_palette.len(); + let color = self.config.blame_palette[n_commits % n_colors].clone(); + if Some(color.as_str()) != other_than_color { + color + } else { + self.config.blame_palette[(n_commits + 1) % n_colors].clone() + } + } +} + +#[derive(Debug)] +pub struct BlameLine<'a> { + pub commit: &'a str, + pub author: &'a str, + pub time: DateTime, + pub line_number: usize, + pub code: &'a str, +} + +// E.g. +//ea82f2d0 (Dan Davison 2021-08-22 18:20:19 -0700 120) let mut handled_line = self.handle_commit_meta_header_line()? + +lazy_static! { + static ref BLAME_LINE_REGEX: Regex = Regex::new( + r"(?x) +^ +( + \^?[0-9a-f]{4,40} # commit hash (^ is 'boundary commit' marker) +) +(?: .+)? # optional file name (unused; present if file has been renamed; TODO: inefficient?) +[\ ] +\( # open ( +( + [^\ ].*[^\ ] # author name +) +[\ ]+ +( # timestamp + [0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}\ [-+][0-9]{4} +) +[\ ]+ +( + [0-9]+ # line number +) +\) # close ) +( + .* # code, with leading space +) +$ +" + ) + .unwrap(); +} + +pub fn parse_git_blame_line<'a>(line: &'a str, timestamp_format: &str) -> Option> { + let caps = BLAME_LINE_REGEX.captures(line)?; + + let commit = caps.get(1).unwrap().as_str(); + let author = caps.get(2).unwrap().as_str(); + let timestamp = caps.get(3).unwrap().as_str(); + + let time = DateTime::parse_from_str(timestamp, timestamp_format).ok()?; + + let line_number = caps.get(4).unwrap().as_str().parse::().ok()?; + + let code = caps.get(5).unwrap().as_str(); + + Some(BlameLine { + commit, + author, + time, + line_number, + code, + }) +} + +lazy_static! { + pub static ref BLAME_PLACEHOLDER_REGEX: Regex = + format::make_placeholder_regex(&["timestamp", "author", "commit"]); +} + +pub fn format_blame_metadata( + format_data: &[format::FormatStringPlaceholderData], + blame: &BlameLine, + is_repeat: bool, + config: &config::Config, +) -> String { + let mut s = String::new(); + let mut suffix = ""; + for placeholder in format_data { + s.push_str(placeholder.prefix.as_str()); + + let alignment_spec = placeholder + .alignment_spec + .as_ref() + .unwrap_or(&format::Align::Left); + let width = placeholder.width.unwrap_or(15); + + let pad = |s| format::pad(s, width, alignment_spec, placeholder.precision); + let field = match placeholder.placeholder { + Some(Placeholder::Str("timestamp")) => Some(Cow::from( + chrono_humanize::HumanTime::from(blame.time).to_string(), + )), + Some(Placeholder::Str("author")) => Some(Cow::from(blame.author)), + Some(Placeholder::Str("commit")) => Some(delta::format_raw_line(blame.commit, config)), + None => None, + _ => unreachable!("Unexpected `git blame` input"), + }; + if let Some(field) = field { + let field = pad(&field); + if is_repeat { + s.push_str(&" ".repeat(measure_text_width(&field))); + } else { + s.push_str(&field) + } + } + suffix = placeholder.suffix.as_str(); + } + s.push_str(suffix); + s +} + +#[cfg(test)] +mod tests { + use itertools::Itertools; + use std::{collections::HashMap, io::Cursor}; + + use crate::tests::integration_test_utils; + + use super::*; + + #[test] + fn test_blame_line_regex() { + for line in &[ + "ea82f2d0 (Dan Davison 2021-08-22 18:20:19 -0700 120) let mut handled_line = self.handle_commit_meta_header_line()?", + "b2257cfa (Dan Davison 2020-07-18 15:34:43 -0400 1) use std::borrow::Cow;", + "^35876eaa (Nicholas Marriott 2009-06-01 22:58:49 +0000 38) /* Default grid cell data. */", + ] { + let caps = BLAME_LINE_REGEX.captures(line); + assert!(caps.is_some()); + assert!(parse_git_blame_line(line, "%Y-%m-%d %H:%M:%S %z").is_some()); + } + } + + #[test] + fn test_color_assignment() { + let mut writer = Cursor::new(vec![0; 512]); + let config = integration_test_utils::make_config_from_args(&["--blame-palette", "1 2"]); + let mut machine = StateMachine::new(&mut writer, &config); + + let blame_lines: HashMap<&str, &str> = vec![ + ( + "A", + "aaaaaaa (Dan Davison 2021-08-22 18:20:19 -0700 120) A", + ), + ( + "B", + "bbbbbbb (Dan Davison 2020-07-18 15:34:43 -0400 1) B", + ), + ( + "C", + "ccccccc (Dan Davison 2020-07-18 15:34:43 -0400 1) C", + ), + ] + .into_iter() + .collect(); + + // First commit gets first color + machine.line = blame_lines["A"].into(); + machine.handle_blame_line().unwrap(); + assert_eq!( + hashmap_items(&machine.blame_commit_colors), + &[("aaaaaaa", "1")] + ); + + // Repeat commit: same color + machine.line = blame_lines["A"].into(); + machine.handle_blame_line().unwrap(); + assert_eq!( + hashmap_items(&machine.blame_commit_colors), + &[("aaaaaaa", "1")] + ); + + // Second distinct commit gets second color + machine.line = blame_lines["B"].into(); + machine.handle_blame_line().unwrap(); + assert_eq!( + hashmap_items(&machine.blame_commit_colors), + &[("aaaaaaa", "1"), ("bbbbbbb", "2")] + ); + + // Third distinct commit gets first color (we only have 2 colors) + machine.line = blame_lines["C"].into(); + machine.handle_blame_line().unwrap(); + assert_eq!( + hashmap_items(&machine.blame_commit_colors), + &[("aaaaaaa", "1"), ("bbbbbbb", "2"), ("ccccccc", "1")] + ); + + // Now the first commit appears again. It would get the first color, but + // that would be a consecutive-commit-color-collision. So it gets the + // second color. + machine.line = blame_lines["A"].into(); + machine.handle_blame_line().unwrap(); + assert_eq!( + hashmap_items(&machine.blame_commit_colors), + &[("aaaaaaa", "2"), ("bbbbbbb", "2"), ("ccccccc", "1")] + ); + } + + fn hashmap_items(hashmap: &HashMap) -> Vec<(&str, &str)> { + hashmap + .iter() + .sorted() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 6302c7396..db27e28fc 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,5 +1,6 @@ /// This module contains functions handling input lines encountered during the /// main `StateMachine::consume()` loop. +pub mod blame; pub mod commit_meta; pub mod diff_stat; pub mod draw; diff --git a/src/options/set.rs b/src/options/set.rs index 05946d43f..656918412 100644 --- a/src/options/set.rs +++ b/src/options/set.rs @@ -122,6 +122,9 @@ pub fn set_options( set_options!( [ + blame_format, + blame_palette, + blame_timestamp_format, color_only, commit_decoration_style, commit_regex, diff --git a/src/paint.rs b/src/paint.rs index 264cecdce..02854a5c8 100644 --- a/src/paint.rs +++ b/src/paint.rs @@ -476,6 +476,7 @@ impl<'p> Painter<'p> { (config.plus_style, config.plus_non_emph_style) } } + State::Blame(_, _) => (diff_sections[0].0, diff_sections[0].0), _ => (config.null_style, config.null_style), }; let fill_style = if style_sections_contain_more_than_one_style(diff_sections) { @@ -621,6 +622,7 @@ impl<'p> Painter<'p> { } State::HunkHeader(_, _) => true, State::HunkMinus(Some(_)) | State::HunkPlus(Some(_)) => false, + State::Blame(_, _) => true, _ => panic!( "should_compute_syntax_highlighting is undefined for state {:?}", state diff --git a/src/style.rs b/src/style.rs index 693583e76..8ce78875b 100644 --- a/src/style.rs +++ b/src/style.rs @@ -127,6 +127,22 @@ impl Style { } } +/// Interpret `color_string` as a color specifier and return it painted accordingly. +pub fn paint_color_string( + color_string: &str, + true_color: bool, +) -> ansi_term::ANSIGenericString { + if let Some(color) = color::parse_color(color_string, true_color) { + let style = ansi_term::Style { + background: Some(color), + ..ansi_term::Style::default() + }; + style.paint(color_string) + } else { + ansi_term::ANSIGenericString::from(color_string) + } +} + impl fmt::Display for Style { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { if self.is_raw { diff --git a/src/subcommands/show_config.rs b/src/subcommands/show_config.rs index e6e6f2467..a9c8db2d7 100644 --- a/src/subcommands/show_config.rs +++ b/src/subcommands/show_config.rs @@ -1,11 +1,14 @@ use std::io::Write; +use itertools::Itertools; + use crate::bat_utils::output::PagingMode; use crate::cli; use crate::config; use crate::features::side_by_side::{Left, Right}; use crate::minusplus::*; use crate::paint::BgFillMethod; +use crate::style; pub fn show_config(config: &config::Config, writer: &mut dyn Write) -> std::io::Result<()> { // styles first @@ -23,7 +26,13 @@ pub fn show_config(config: &config::Config, writer: &mut dyn Write) -> std::io:: plus-non-emph-style = {plus_non_emph_style} plus-emph-style = {plus_emph_style} plus-empty-line-marker-style = {plus_empty_line_marker_style} - whitespace-error-style = {whitespace_error_style}", + whitespace-error-style = {whitespace_error_style} + blame-palette = {blame_palette}", + blame_palette = config + .blame_palette + .iter() + .map(|s| style::paint_color_string(s, config.true_color)) + .join(" "), commit_style = config.commit_style.to_painted_string(), file_style = config.file_style.to_painted_string(), hunk_header_style = config.hunk_header_style.to_painted_string(),