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 f7ad73564..6a3356290 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -419,6 +419,33 @@ 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} {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, + /// The regular expression used to decide what a word is for the within-line highlight /// algorithm. For less fine-grained matching than the default try --word-diff-regex="\S+" /// --max-line-distance=1.0 (this is more similar to `git --word-diff`). diff --git a/src/config.rs b/src/config.rs index 9b1c23cef..71580ddb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,11 +21,15 @@ 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, pub cwd_relative_to_repo_root: Option, pub decorations_width: cli::Width, + pub default_language: Option, pub diff_stat_align_width: usize, pub error_exit_code: i32, pub file_added_label: String, @@ -201,11 +205,19 @@ 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, cwd_relative_to_repo_root: std::env::var("GIT_PREFIX").ok(), decorations_width: opt.computed.decorations_width, + default_language: opt.default_language, diff_stat_align_width: opt.diff_stat_align_width, error_exit_code: 2, // Use 2 for error because diff uses 0 and 1 for non-error. file_added_label, diff --git a/src/delta.rs b/src/delta.rs index 53e2add8b..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. @@ -138,7 +145,7 @@ impl<'a> StateMachine<'a> { writeln!( self.painter.writer, "{}", - format::format_raw_line(&self.raw_line, self.config) + format_raw_line(&self.raw_line, self.config) )?; } } @@ -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; @@ -633,6 +704,16 @@ 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. +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 { + Cow::from(line) + } +} + /// Write `line` with FileMeta styling. fn _write_generic_file_meta_header_line( line: &str, 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/features/line_numbers.rs b/src/features/line_numbers.rs index f91898411..c464f8c73 100644 --- a/src/features/line_numbers.rs +++ b/src/features/line_numbers.rs @@ -8,6 +8,7 @@ use crate::delta::State; use crate::features::hyperlinks; use crate::features::side_by_side; use crate::features::OptionValueFunction; +use crate::format; use crate::style::Style; pub fn make_feature() -> Vec<(String, OptionValueFunction)> { @@ -134,28 +135,13 @@ pub fn format_and_paint_line_numbers<'a>( } lazy_static! { - static ref LINE_NUMBERS_PLACEHOLDER_REGEX: Regex = Regex::new( - r"(?x) -\{ -(nm|np) # 1: Literal nm or np -(?: # Start optional format spec (non-capturing) - : # Literal colon - (?: # Start optional fill/alignment spec (non-capturing) - ([^<^>])? # 2: Optional fill character (ignored) - ([<^>]) # 3: Alignment spec - )? # - (\d+) # 4: Width -)? # -\} -" - ) - .unwrap(); + static ref LINE_NUMBERS_PLACEHOLDER_REGEX: Regex = format::make_placeholder_regex(&["nm", "np"]); } #[derive(Default)] pub struct LineNumbersData<'a> { - pub left_format_data: LineNumberFormatData<'a>, - pub right_format_data: LineNumberFormatData<'a>, + pub left_format_data: format::FormatStringData<'a>, + pub right_format_data: format::FormatStringData<'a>, pub hunk_minus_line_number: usize, pub hunk_plus_line_number: usize, pub hunk_max_line_number_width: usize, @@ -164,22 +150,17 @@ pub struct LineNumbersData<'a> { // Although it's probably unusual, a single format string can contain multiple placeholders. E.g. // line-numbers-right-format = "{nm} {np}|" -pub type LineNumberFormatData<'a> = Vec>; - -#[derive(Debug, Default, PartialEq)] -pub struct LineNumberPlaceholderData<'a> { - pub prefix: &'a str, - pub placeholder: Option<&'a str>, - pub alignment_spec: Option<&'a str>, - pub width: Option, - pub suffix: &'a str, -} - impl<'a> LineNumbersData<'a> { pub fn from_format_strings(left_format: &'a str, right_format: &'a str) -> LineNumbersData<'a> { Self { - left_format_data: parse_line_number_format(left_format), - right_format_data: parse_line_number_format(right_format), + left_format_data: format::parse_line_number_format( + left_format, + &*LINE_NUMBERS_PLACEHOLDER_REGEX, + ), + right_format_data: format::parse_line_number_format( + right_format, + &*LINE_NUMBERS_PLACEHOLDER_REGEX, + ), hunk_minus_line_number: 0, hunk_plus_line_number: 0, hunk_max_line_number_width: 0, @@ -200,41 +181,9 @@ impl<'a> LineNumbersData<'a> { } } -fn parse_line_number_format(format_string: &str) -> LineNumberFormatData { - let mut format_data = Vec::new(); - let mut offset = 0; - - for captures in LINE_NUMBERS_PLACEHOLDER_REGEX.captures_iter(format_string) { - let _match = captures.get(0).unwrap(); - format_data.push(LineNumberPlaceholderData { - prefix: &format_string[offset.._match.start()], - placeholder: captures.get(1).map(|m| m.as_str()), - alignment_spec: captures.get(3).map(|m| m.as_str()), - width: captures.get(4).map(|m| { - m.as_str() - .parse() - .unwrap_or_else(|_| panic!("Invalid width in format string: {}", format_string)) - }), - suffix: &format_string[_match.end()..], - }); - offset = _match.end(); - } - if offset == 0 { - // No placeholders - format_data.push(LineNumberPlaceholderData { - prefix: &format_string[..0], - placeholder: None, - alignment_spec: None, - width: None, - suffix: &format_string[0..], - }) - } - format_data -} - #[allow(clippy::too_many_arguments)] fn format_and_paint_line_number_field<'a>( - format_data: &[LineNumberPlaceholderData<'a>], + format_data: &[format::FormatStringPlaceholderData<'a>], style: &Style, minus_number: Option, plus_number: Option, @@ -288,22 +237,14 @@ fn format_line_number( plus_file: Option<&str>, config: &config::Config, ) -> String { - let format_n = |n| match alignment { - "<" => format!("{0:<1$}", n, width), - "^" => format!("{0:^1$}", n, width), - ">" => format!("{0:>1$}", n, width), - _ => unreachable!(), - }; + let pad = |n| format::pad(n, width, alignment); match (line_number, config.hyperlinks, plus_file) { - (None, _, _) => format_n(""), - (Some(n), true, Some(file)) => hyperlinks::format_osc8_file_hyperlink( - file, - line_number, - &format_n(&n.to_string()), - config, - ) - .to_string(), - (Some(n), _, _) => format_n(&n.to_string()), + (None, _, _) => pad(""), + (Some(n), true, Some(file)) => { + hyperlinks::format_osc8_file_hyperlink(file, line_number, &pad(&n.to_string()), config) + .to_string() + } + (Some(n), _, _) => pad(&n.to_string()), } } @@ -319,8 +260,8 @@ pub mod tests { #[test] fn test_line_number_format_regex_1() { assert_eq!( - parse_line_number_format("{nm}"), - vec![LineNumberPlaceholderData { + format::parse_line_number_format("{nm}", &LINE_NUMBERS_PLACEHOLDER_REGEX), + vec![format::FormatStringPlaceholderData { prefix: "", placeholder: Some("nm"), alignment_spec: None, @@ -333,8 +274,8 @@ pub mod tests { #[test] fn test_line_number_format_regex_2() { assert_eq!( - parse_line_number_format("{np:4}"), - vec![LineNumberPlaceholderData { + format::parse_line_number_format("{np:4}", &LINE_NUMBERS_PLACEHOLDER_REGEX), + vec![format::FormatStringPlaceholderData { prefix: "", placeholder: Some("np"), alignment_spec: None, @@ -347,8 +288,8 @@ pub mod tests { #[test] fn test_line_number_format_regex_3() { assert_eq!( - parse_line_number_format("{np:>4}"), - vec![LineNumberPlaceholderData { + format::parse_line_number_format("{np:>4}", &LINE_NUMBERS_PLACEHOLDER_REGEX), + vec![format::FormatStringPlaceholderData { prefix: "", placeholder: Some("np"), alignment_spec: Some(">"), @@ -361,8 +302,8 @@ pub mod tests { #[test] fn test_line_number_format_regex_4() { assert_eq!( - parse_line_number_format("{np:_>4}"), - vec![LineNumberPlaceholderData { + format::parse_line_number_format("{np:_>4}", &LINE_NUMBERS_PLACEHOLDER_REGEX), + vec![format::FormatStringPlaceholderData { prefix: "", placeholder: Some("np"), alignment_spec: Some(">"), @@ -375,8 +316,8 @@ pub mod tests { #[test] fn test_line_number_format_regex_5() { assert_eq!( - parse_line_number_format("__{np:_>4}@@"), - vec![LineNumberPlaceholderData { + format::parse_line_number_format("__{np:_>4}@@", &LINE_NUMBERS_PLACEHOLDER_REGEX), + vec![format::FormatStringPlaceholderData { prefix: "__", placeholder: Some("np"), alignment_spec: Some(">"), @@ -389,16 +330,19 @@ pub mod tests { #[test] fn test_line_number_format_regex_6() { assert_eq!( - parse_line_number_format("__{nm:<3}@@---{np:_>4}**"), + format::parse_line_number_format( + "__{nm:<3}@@---{np:_>4}**", + &LINE_NUMBERS_PLACEHOLDER_REGEX + ), vec![ - LineNumberPlaceholderData { + format::FormatStringPlaceholderData { prefix: "__", placeholder: Some("nm"), alignment_spec: Some("<"), width: Some(3), suffix: "@@---{np:_>4}**", }, - LineNumberPlaceholderData { + format::FormatStringPlaceholderData { prefix: "@@---", placeholder: Some("np"), alignment_spec: Some(">"), @@ -412,8 +356,8 @@ pub mod tests { #[test] fn test_line_number_format_regex_7() { assert_eq!( - parse_line_number_format("__@@---**"), - vec![LineNumberPlaceholderData { + format::parse_line_number_format("__@@---**", &LINE_NUMBERS_PLACEHOLDER_REGEX), + vec![format::FormatStringPlaceholderData { prefix: "", placeholder: None, alignment_spec: None, diff --git a/src/format.rs b/src/format.rs index 5c1bf39f0..67d1f509a 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,14 +1,76 @@ -use std::borrow::Cow; - -use crate::config::Config; -use crate::features; - -/// If output is going to a tty, emit hyperlinks if requested. -// Although raw output should basically be emitted unaltered, we do this. -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 { - Cow::from(line) +use regex::Regex; + +#[derive(Debug, Default, PartialEq)] +pub struct FormatStringPlaceholderData<'a> { + pub prefix: &'a str, + pub placeholder: Option<&'a str>, + pub alignment_spec: Option<&'a str>, + pub width: Option, + pub suffix: &'a str, +} + +pub type FormatStringData<'a> = Vec>; + +pub fn make_placeholder_regex(labels: &[&str]) -> Regex { + Regex::new(&format!( + r"(?x) + \{{ + ({}) # 1: Placeholder labels + (?: # Start optional format spec (non-capturing) + : # Literal colon + (?: # Start optional fill/alignment spec (non-capturing) + ([^<^>])? # 2: Optional fill character (ignored) + ([<^>]) # 3: Alignment spec + )? # + (\d+) # 4: Width + )? # + \}} + ", + labels.join("|") + )) + .unwrap() +} + +pub fn parse_line_number_format<'a>( + format_string: &'a str, + placeholder_regex: &Regex, +) -> FormatStringData<'a> { + let mut format_data = Vec::new(); + let mut offset = 0; + + for captures in placeholder_regex.captures_iter(format_string) { + let _match = captures.get(0).unwrap(); + format_data.push(FormatStringPlaceholderData { + prefix: &format_string[offset.._match.start()], + placeholder: captures.get(1).map(|m| m.as_str()), + alignment_spec: captures.get(3).map(|m| m.as_str()), + width: captures.get(4).map(|m| { + m.as_str() + .parse() + .unwrap_or_else(|_| panic!("Invalid width in format string: {}", format_string)) + }), + suffix: &format_string[_match.end()..], + }); + offset = _match.end(); + } + if offset == 0 { + // No placeholders + format_data.push(FormatStringPlaceholderData { + prefix: &format_string[..0], + placeholder: None, + alignment_spec: None, + width: None, + suffix: &format_string[0..], + }) + } + format_data +} + +pub fn pad(s: &str, width: usize, alignment: &str) -> String { + match alignment { + "<" => format!("{0:<1$}", s, width), + "^" => format!("{0:^1$}", s, width), + ">" => format!("{0:>1$}", s, width), + _ => unreachable!(), } } diff --git a/src/hunk_header.rs b/src/hunk_header.rs index b3c596cf1..7743e8e45 100644 --- a/src/hunk_header.rs +++ b/src/hunk_header.rs @@ -21,14 +21,12 @@ use std::fmt::Write as FmtWrite; -use unicode_segmentation::UnicodeSegmentation; - use crate::config::Config; use crate::delta; use crate::draw; use crate::features; use crate::paint::Painter; -use crate::style::DecorationStyle; +use crate::style::{DecorationStyle, Style}; pub fn write_hunk_header_raw( painter: &mut Painter, @@ -73,7 +71,12 @@ pub fn write_hunk_header( let file_with_line_number = get_painted_file_with_line_number(line_numbers, plus_file, config); if !line.is_empty() || !file_with_line_number.is_empty() { - write_to_output_buffer(&file_with_line_number, line, painter, config); + write_to_output_buffer( + &file_with_line_number, + line, + config.hunk_header_style, + painter, + ); draw_fn( painter.writer, &painter.output_buffer, @@ -133,33 +136,18 @@ fn get_painted_file_with_line_number( fn write_to_output_buffer( file_with_line_number: &str, line: String, + style: Style, painter: &mut Painter, - config: &Config, ) { if !file_with_line_number.is_empty() { let _ = write!(&mut painter.output_buffer, "{}: ", file_with_line_number); } if !line.is_empty() { - let lines = vec![( - painter.expand_tabs(line.graphemes(true)), + painter.syntax_highlight_and_paint_line( + &line, + style, delta::State::HunkHeader("".to_owned(), "".to_owned()), - )]; - let syntax_style_sections = Painter::get_syntax_style_sections_for_lines( - &lines, - &delta::State::HunkHeader("".to_owned(), "".to_owned()), - painter.highlighter.as_mut(), - painter.config, - ); - Painter::paint_lines( - syntax_style_sections, - vec![vec![(config.hunk_header_style, &lines[0].0)]], // TODO: compute style from state - [delta::State::HunkHeader("".to_owned(), "".to_owned())].iter(), - &mut painter.output_buffer, - config, - &mut None, - None, - None, - Some(false), + false, ); painter.output_buffer.pop(); // trim newline } 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 53240fce8..ab8885171 100644 --- a/src/options/set.rs +++ b/src/options/set.rs @@ -121,10 +121,14 @@ pub fn set_options( set_options!( [ + blame_format, + blame_palette, + blame_timestamp_format, color_only, commit_decoration_style, commit_regex, commit_style, + default_language, diff_stat_align_width, file_added_label, file_copied_label, @@ -599,6 +603,7 @@ pub mod tests { commit-decoration-style = black black commit-style = black black dark = false + default-language = rs diff-highlight = true diff-so-fancy = true features = xxxyyyzzz @@ -656,6 +661,7 @@ pub mod tests { assert_eq!(opt.commit_decoration_style, "black black"); assert_eq!(opt.commit_style, "black black"); assert_eq!(opt.dark, false); + assert_eq!(opt.default_language, Some("rs".to_owned())); // TODO: should set_options not be called on any feature flags? // assert_eq!(opt.diff_highlight, true); // assert_eq!(opt.diff_so_fancy, true); diff --git a/src/paint.rs b/src/paint.rs index 2236ec2dd..a575d3bf5 100644 --- a/src/paint.rs +++ b/src/paint.rs @@ -291,6 +291,35 @@ impl<'a> Painter<'a> { } } + /// Write painted line to the output buffer, with syntax-highlighting and `style` superimposed. + pub fn syntax_highlight_and_paint_line( + &mut self, + line: &str, + style: Style, + state: State, + background_color_extends_to_terminal_width: bool, + ) { + let lines = vec![(self.expand_tabs(line.graphemes(true)), state.clone())]; + let syntax_style_sections = Painter::get_syntax_style_sections_for_lines( + &lines, + &state, + self.highlighter.as_mut(), + self.config, + ); + let diff_style_sections = vec![vec![(style, lines[0].0.as_str())]]; // TODO: compute style from state + Painter::paint_lines( + syntax_style_sections, + diff_style_sections, + [state].iter(), + &mut self.output_buffer, + self.config, + &mut None, + None, + None, + Some(background_color_extends_to_terminal_width), + ); + } + /// Determine whether the terminal should fill the line rightwards with a background color, and /// the style for doing so. pub fn get_should_right_fill_background_color_and_fill_style( @@ -329,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) { @@ -465,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