summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDan Davison <dandavison7@gmail.com>2021-08-28 20:21:42 -0400
committerDan Davison <dandavison7@gmail.com>2021-08-28 21:36:57 -0400
commit19eec9bd3e037cd542dfafa40e067098fad917ac (patch)
tree9975e283d01824e500f670ec71863ab4ea79798d
parent6afd3705d801f03772e5d74b7e137edc169d5e8e (diff)
Handle blame output
Fixes #291, #426
-rw-r--r--Cargo.lock33
-rw-r--r--Cargo.toml2
-rw-r--r--src/blame.rs122
-rw-r--r--src/cli.rs27
-rw-r--r--src/config.rs10
-rw-r--r--src/delta.rs77
-rw-r--r--src/features/hyperlinks.rs4
-rw-r--r--src/main.rs1
-rw-r--r--src/options/set.rs3
-rw-r--r--src/paint.rs2
10 files changed, 272 insertions, 9 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 2c115db1..dc970c6f 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",
@@ -834,6 +848,17 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -959,6 +984,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 8a960b3e..8de22eb4 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 00000000..bf15fdb1
--- /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<FixedOffset>,
+ 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<BlameLine<'a>> {
+ 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::<usize>() {
+ 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 cebbd282..6a335629 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<String>,
+
+ /// 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<String>,
diff --git a/src/config.rs b/src/config.rs
index d45c2124..71580ddb 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<Vec<String>>,
+ pub blame_timestamp_format: String,
pub commit_style: Style,
pub color_only: bool,
pub commit_regex: Regex,
@@ -202,6 +205,13 @@ impl From<cli::Opt> 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::<Vec<String>>()
+ }),
+ 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 bb8c1afc..61dd475c 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<String>), // 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<String, String>,
}
pub fn delta<I>(lines: ByteLines<I>, 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<bool> {
+ 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<bool> {
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 2d3ad3b0..6637734c 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 ccf82661..a248c847 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 34d87bdf..ab888517 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 f807146a..a575d3bf 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