From 6f88bf5c9c66bbfc3ca1cdcb2e0aa22a00069661 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sat, 28 Aug 2021 20:21:42 -0400 Subject: [PATCH] Handle blame output Fixes #291, #426 --- Cargo.lock | 33 +++++++++- Cargo.toml | 2 + src/blame.rs | 122 +++++++++++++++++++++++++++++++++++++ src/cli.rs | 27 +++++++- src/config.rs | 10 +++ src/delta.rs | 77 ++++++++++++++++++++++- src/features/hyperlinks.rs | 4 +- src/main.rs | 1 + src/options/set.rs | 3 + src/paint.rs | 2 + 10 files changed, 272 insertions(+), 9 deletions(-) create mode 100644 src/blame.rs diff --git a/Cargo.lock b/Cargo.lock index 2c115db1d..dc970c6f3 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]] @@ -276,7 +288,7 @@ checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.9.0+wasi-snapshot-preview1", ] [[package]] @@ -289,6 +301,8 @@ dependencies = [ "bitflags", "box_drawing", "bytelines", + "chrono", + "chrono-humanize", "console", "ctrlc", "dirs-next", @@ -833,6 +847,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" @@ -958,6 +983,12 @@ version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 8a960b3e0..8de22eb4e 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/src/blame.rs b/src/blame.rs new file mode 100644 index 000000000..bf15fdb1f --- /dev/null +++ b/src/blame.rs @@ -0,0 +1,122 @@ +use chrono::{DateTime, FixedOffset}; +use lazy_static::lazy_static; +use regex::Regex; + +use crate::config; +use crate::delta; +use crate::format; + +#[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]{8} # commit hash +) +[\ ] +\( # 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> { + if let Some(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(); + if let Ok(time) = DateTime::parse_from_str(timestamp, timestamp_format) { + let line_number_str = caps.get(4).unwrap().as_str(); + if let Ok(line_number) = line_number_str.parse::() { + let code = caps.get(5).unwrap().as_str(); + Some(BlameLine { + commit, + author, + time, + line_number, + code, + }) + } else { + None + } + } else { + None + } + } else { + None + } +} + +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, + config: &config::Config, +) -> String { + let mut s = String::new(); + let mut suffix = ""; + for placeholder in format_data { + s.push_str(placeholder.prefix); + + let alignment_spec = placeholder.alignment_spec.unwrap_or("<"); + let width = placeholder.width.unwrap_or(15); + + let pad = |s| format::pad(s, width, alignment_spec); + match placeholder.placeholder { + Some("timestamp") => s.push_str(&pad( + &chrono_humanize::HumanTime::from(blame.time).to_string() + )), + Some("author") => s.push_str(&pad(blame.author)), + Some("commit") => s.push_str(&pad(&delta::format_raw_line(blame.commit, config))), + None => {} + Some(_) => unreachable!(), + } + suffix = placeholder.suffix; + } + s.push_str(suffix); + s +} + +#[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;" + ] { + 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()); + } +} diff --git a/src/cli.rs b/src/cli.rs index cebbd2825..6a3356290 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -419,9 +419,30 @@ pub struct Opt { /// (underline), 'ol' (overline), or the combination 'ul ol'. pub hunk_header_decoration_style: 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) + /// Format string for git blame commit metadata. Available placeholders are + /// "{timestamp}", "{author}", and "{commit}". + #[structopt( + long = "blame-format", + default_value = "{timestamp:<15} {author:<15} {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) #[structopt(long = "default-language")] pub default_language: Option, diff --git a/src/config.rs b/src/config.rs index d45c2124a..71580ddb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,9 @@ use crate::style::{self, Style}; pub struct Config { pub available_terminal_width: usize, pub background_color_extends_to_terminal_width: bool, + pub blame_format: String, + pub blame_palette: Option>, + pub blame_timestamp_format: String, pub commit_style: Style, pub color_only: bool, pub commit_regex: Regex, @@ -202,6 +205,13 @@ 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: opt.blame_palette.map(|s| { + s.split_whitespace() + .map(|s| s.to_owned()) + .collect::>() + }), + blame_timestamp_format: opt.blame_timestamp_format, commit_style, color_only: opt.color_only, commit_regex, diff --git a/src/delta.rs b/src/delta.rs index bb8c1afcc..61dd475c5 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; @@ -6,7 +7,9 @@ use bytelines::ByteLines; use unicode_segmentation::UnicodeSegmentation; use crate::ansi; +use crate::blame; use crate::cli; +use crate::color; use crate::config::Config; use crate::draw; use crate::features; @@ -14,7 +17,7 @@ use crate::format; use crate::hunk_header; use crate::paint::Painter; use crate::parse; -use crate::style::{self, DecorationStyle}; +use crate::style::{self, DecorationStyle, Style}; #[derive(Clone, Debug, PartialEq)] pub enum State { @@ -26,6 +29,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), // In a line of `git blame` output. Unknown, } @@ -77,6 +81,7 @@ struct StateMachine<'a> { // avoid emitting the file meta header line twice (#245). current_file_pair: Option<(String, String)>, handled_file_meta_header_line_file_pair: Option<(String, String)>, + blame_commit_colors: HashMap, } pub fn delta(lines: ByteLines, writer: &mut dyn Write, config: &Config) -> std::io::Result<()> @@ -102,6 +107,7 @@ impl<'a> StateMachine<'a> { handled_file_meta_header_line_file_pair: None, painter: Painter::new(writer, config), config, + blame_commit_colors: HashMap::new(), } } @@ -126,7 +132,8 @@ impl<'a> StateMachine<'a> { || self.handle_additional_file_meta_cases()? || self.handle_submodule_log_line()? || self.handle_submodule_short_line()? - || self.handle_hunk_line()?; + || self.handle_hunk_line()? + || self.handle_blame_line()?; if self.state == State::FileMeta && self.should_handle() && !self.config.color_only { // Skip file metadata lines unless a raw diff style has been requested. @@ -463,6 +470,70 @@ impl<'a> StateMachine<'a> { Ok(true) } + /// 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. + fn handle_blame_line(&mut self) -> std::io::Result { + let mut handled_line = false; + self.painter.emit()?; + if matches!(self.state, State::Unknown | State::Blame(_)) { + if let Some(blame) = + blame::parse_git_blame_line(&self.line, &self.config.blame_timestamp_format) + { + // Determine color for this line + let color = if let Some(color) = self.blame_commit_colors.get(blame.commit) { + color + } else { + let n_commits = self.blame_commit_colors.len(); + let n_colors = self.config.blame_palette.as_ref().map(|v| v.len()).unwrap(); + let new_color = self + .config + .blame_palette + .as_ref() + .map(|v| &v[(n_commits + 1) % n_colors]) + .unwrap(); + self.blame_commit_colors + .insert(blame.commit.to_owned(), new_color.to_owned()); + new_color + }; + let mut style = Style::from_colors(None, color::parse_color(color, true)); + style.is_syntax_highlighted = true; + + // Construct commit metadata, paint, and emit + let format_data = format::parse_line_number_format( + &self.config.blame_format, + &*blame::BLAME_PLACEHOLDER_REGEX, + ); + write!( + self.painter.writer, + "{}", + style.paint(blame::format_blame_metadata( + &format_data, + &blame, + self.config + )) + )?; + + // Emit syntax-highlighted code + if matches!(self.state, State::Unknown) { + if let Some(lang) = self.config.default_language.as_ref() { + self.painter.set_syntax(Some(lang)); + self.painter.set_highlighter(); + } + self.state = State::Blame(blame.commit.to_owned()); + } + self.painter.syntax_highlight_and_paint_line( + blame.code, + style, + self.state.clone(), + true, + ); + handled_line = true + } + } + Ok(handled_line) + } + fn _handle_additional_cases(&mut self, to_state: State) -> std::io::Result { let mut handled_line = false; @@ -635,7 +706,7 @@ impl<'a> StateMachine<'a> { /// If output is going to a tty, emit hyperlinks if requested. // Although raw output should basically be emitted unaltered, we do this. -fn format_raw_line<'a>(line: &'a str, config: &Config) -> Cow<'a, str> { +pub fn format_raw_line<'a>(line: &'a str, config: &Config) -> Cow<'a, str> { if config.hyperlinks && atty::is(atty::Stream::Stdout) { features::hyperlinks::format_commit_line_with_osc8_commit_hyperlink(line, config) } else { diff --git a/src/features/hyperlinks.rs b/src/features/hyperlinks.rs index 2d3ad3b0b..6637734cb 100644 --- a/src/features/hyperlinks.rs +++ b/src/features/hyperlinks.rs @@ -88,7 +88,7 @@ fn format_osc8_hyperlink(url: &str, text: &str) -> String { } lazy_static! { - static ref COMMIT_LINE_REGEX: Regex = Regex::new("(.* )([0-9a-f]{40})(.*)").unwrap(); + static ref COMMIT_LINE_REGEX: Regex = Regex::new("(.* )?([0-9a-f]{8,40})(.*)").unwrap(); } fn format_commit_line_captures_with_osc8_commit_hyperlink( @@ -100,7 +100,7 @@ fn format_commit_line_captures_with_osc8_commit_hyperlink( "{prefix}{osc}8;;{url}{st}{commit}{osc}8;;{st}{suffix}", url = format_github_commit_url(commit, github_repo), commit = commit, - prefix = captures.get(1).unwrap().as_str(), + prefix = captures.get(1).map(|m| m.as_str()).unwrap_or(""), suffix = captures.get(3).unwrap().as_str(), osc = "\x1b]", st = "\x1b\\" diff --git a/src/main.rs b/src/main.rs index ccf826611..a248c8472 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod align; mod ansi; #[cfg(not(tarpaulin_include))] mod bat_utils; +mod blame; mod cli; mod color; mod config; diff --git a/src/options/set.rs b/src/options/set.rs index 34d87bdfc..ab8885171 100644 --- a/src/options/set.rs +++ b/src/options/set.rs @@ -121,6 +121,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 f807146a2..a575d3bf5 100644 --- a/src/paint.rs +++ b/src/paint.rs @@ -358,6 +358,7 @@ impl<'a> Painter<'a> { (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) { @@ -494,6 +495,7 @@ impl<'a> Painter<'a> { } State::HunkHeader(_, _) => true, State::HunkMinus(Some(_)) | State::HunkPlus(Some(_)) => false, + State::Blame(_) => true, _ => panic!( "should_compute_syntax_highlighting is undefined for state {:?}", state