From c825014f673db32a74641b334a2e59cc621ba694 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 14 Nov 2021 14:15:51 -0500 Subject: [PATCH] Handle grep output - Handle standard filepath:code and filepath:line_number:code output as produced by `git grep`, `rg -H`, `grep -H`, etc (with -n for line numbers). - Retain the match highlighting as produced by the grep tool, and expose it in delta's color output styled with grep-match-style. (Note that --color=always is needed to retain the color if piping into delta, but not for `git grep` when delta is configured as git's pager) - Special handling of -p, and -W options of `git grep`: these display the function context in which the matches occur. - `navigate` keybindings jump between match function contexts under `git grep -p` and between matching lines under `git grep -W`. Thanks @zachriggle for the proposal. Fixes #769 --- src/cli.rs | 15 ++ src/config.rs | 22 +++ src/delta.rs | 2 + src/handlers/file_meta.rs | 2 +- src/handlers/grep.rs | 380 ++++++++++++++++++++++++++++++++++++ src/handlers/hunk_header.rs | 2 +- src/handlers/mod.rs | 1 + src/options/set.rs | 3 + src/paint.rs | 6 + 9 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 src/handlers/grep.rs diff --git a/src/cli.rs b/src/cli.rs index 9ae6130fd..ac42c925b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -441,6 +441,21 @@ pub struct Opt { )] pub blame_timestamp_format: String, + #[structopt(long = "grep-match-style")] + /// Style (foreground, background, attributes) for grep matches. See STYLES + /// section. Defaults to plus-style. + pub grep_match_style: Option, + + #[structopt(long = "grep-match-file-style")] + /// Style (foreground, background, attributes) for grep match file paths. See STYLES + /// section. Defaults to hunk-header-file-path-style. + pub grep_match_file_style: Option, + + #[structopt(long = "grep-match-line-number-style")] + /// Style (foreground, background, attributes) for grep match line numbers. See STYLES + /// section. Defaults to hunk-header-line-number-style. + pub grep_match_line_number_style: Option, + /// 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/config.rs b/src/config.rs index 3cba6ba3a..98b81fa35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -78,6 +78,9 @@ pub struct Config { pub git_config: Option, pub git_minus_style: Style, pub git_plus_style: Style, + pub grep_match_file_style: Style, + pub grep_match_line_number_style: Style, + pub grep_match_style: Style, pub hunk_header_file_style: Style, pub hunk_header_line_number_style: Style, pub hunk_header_style_include_file_path: bool, @@ -216,6 +219,22 @@ impl From for Config { _ => *style::GIT_DEFAULT_PLUS_STYLE, }; + let grep_match_style = if let Some(s) = opt.grep_match_style { + Style::from_str(&s, None, None, opt.computed.true_color, false) + } else { + plus_emph_style + }; + let grep_match_file_style = if let Some(s) = opt.grep_match_file_style { + Style::from_str(&s, None, None, opt.computed.true_color, false) + } else { + hunk_header_file_style + }; + let grep_match_line_number_style = if let Some(s) = opt.grep_match_line_number_style { + Style::from_str(&s, None, None, opt.computed.true_color, false) + } else { + hunk_header_line_number_style + }; + let blame_palette = make_blame_palette(opt.blame_palette, opt.computed.is_light_mode); let file_added_label = opt.file_added_label; @@ -282,6 +301,9 @@ impl From for Config { file_style, git_config: opt.git_config, git_config_entries: opt.git_config_entries, + grep_match_file_style, + grep_match_line_number_style, + grep_match_style, hunk_header_file_style, hunk_header_line_number_style, hunk_header_style, diff --git a/src/delta.rs b/src/delta.rs index fbf45c53d..fe8be9260 100644 --- a/src/delta.rs +++ b/src/delta.rs @@ -23,6 +23,7 @@ pub enum State { 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). + Grep(String, Option), // In a line of `git grep` output (file, repeat_grep_line). Unknown, // The following elements are created when a line is wrapped to display it: HunkZeroWrapped, // Wrapped unchanged line @@ -121,6 +122,7 @@ impl<'a> StateMachine<'a> { || self.handle_submodule_short_line()? || self.handle_hunk_line()? || self.handle_blame_line()? + || self.handle_grep_line()? || self.should_skip_line() || self.emit_line_unchanged()?; } diff --git a/src/handlers/file_meta.rs b/src/handlers/file_meta.rs index 82109a910..104f68161 100644 --- a/src/handlers/file_meta.rs +++ b/src/handlers/file_meta.rs @@ -210,7 +210,7 @@ fn get_file_extension_from_file_meta_line_file_path(path: &str) -> Option<&str> } /// Attempt to parse input as a file path and return extension as a &str. -fn get_extension(s: &str) -> Option<&str> { +pub fn get_extension(s: &str) -> Option<&str> { let path = Path::new(s); path.extension() .and_then(|e| e.to_str()) diff --git a/src/handlers/grep.rs b/src/handlers/grep.rs new file mode 100644 index 000000000..0b1dd9859 --- /dev/null +++ b/src/handlers/grep.rs @@ -0,0 +1,380 @@ +// TODO +// Bad parsing: "etc/examples/119-within-line-edits:4:repo=$(mktemp -d)" +// Parsing "Makefile" +// Inspect process tree once +use std::convert::{TryFrom, TryInto}; + +use lazy_static::lazy_static; +use regex::Regex; +use unicode_segmentation::UnicodeSegmentation; + +use crate::ansi; +use crate::delta::{State, StateMachine}; +use crate::handlers; +use crate::paint::{self, BgShouldFill, StyleSectionSpecifier}; +use crate::style::Style; +use crate::utils; + +struct GrepOutputConfig { + add_navigate_marker_to_matches: bool, + render_context_header_as_hunk_header: bool, + pad_line_number: bool, +} + +impl<'a> StateMachine<'a> { + /// If this is a line of git grep output then render it accordingly. If this + /// is the first grep line, then set the syntax-highlighter language. + pub fn handle_grep_line(&mut self) -> std::io::Result { + self.painter.emit()?; + let mut handled_line = false; + + // TODO: It should be possible to eliminate some of the .clone()s and + // .to_owned()s. + let (_previous_file, repeat_grep_line, try_parse) = match &self.state { + State::Grep(file, repeat_grep_line) => { + (Some(file.as_str()), repeat_grep_line.clone(), true) + } + State::Unknown => (None, None, true), + _ => (None, None, false), + }; + if try_parse { + if let Some(grep) = parse_git_grep_line(&self.line) { + let output_config = make_output_config(); + + // Emit syntax-highlighted code + // TODO: Determine the language less frequently, e.g. only when the file changes. + if let Some(lang) = handlers::file_meta::get_extension(grep.file) + .or_else(|| self.config.default_language.as_deref()) + { + self.painter.set_syntax(Some(lang)); + self.painter.set_highlighter(); + } + self.state = State::Grep(grep.file.to_owned(), repeat_grep_line); + + match ( + &grep.line_type, + output_config.render_context_header_as_hunk_header, + ) { + // Emit context header line + (LineType::ContextHeader, true) => handlers::hunk_header::write_hunk_header( + grep.code, + &[(grep.line_number.unwrap_or(0), 0)], + &mut self.painter, + &self.line, + grep.file, + self.config, + )?, + _ => { + if self.config.navigate { + write!( + self.painter.writer, + "{}", + match ( + &grep.line_type, + output_config.add_navigate_marker_to_matches + ) { + (LineType::Hit, true) => "• ", + (_, true) => " ", + _ => "", + } + )? + } + // Emit file & line-number + write!( + self.painter.writer, + "{}", + paint::paint_file_path_with_line_number( + grep.line_number, + grep.file, + output_config.pad_line_number, + true, + Some(self.config.grep_match_file_style), + Some(self.config.grep_match_line_number_style), + self.config + ) + )?; + + // Emit code line + let code_style_sections = if matches!(&grep.line_type, LineType::Hit) { + // HACK: We need tabs expanded, and we need the &str + // passed to `get_code_style_sections` to live long + // enough. + self.raw_line = self.painter.expand_tabs(self.raw_line.graphemes(true)); + get_code_style_sections( + &self.raw_line, + self.config.grep_match_style, + &grep, + ) + .unwrap_or(StyleSectionSpecifier::Style(self.config.zero_style)) + } else { + StyleSectionSpecifier::Style(self.config.zero_style) + }; + self.painter.syntax_highlight_and_paint_line( + &format!("{}\n", grep.code), + code_style_sections, + self.state.clone(), + BgShouldFill::default(), + ) + } + } + handled_line = true + } + } + Ok(handled_line) + } +} + +// Return style sections describing colors received from git. +fn get_code_style_sections<'b>( + raw_line: &'b str, + match_style: Style, + grep: &GrepLine, +) -> Option> { + if let Some(raw_code_start) = ansi::ansi_preserving_index( + raw_line, + match grep.line_number { + Some(n) => format!("{}:{}:", grep.file, n).len(), + None => grep.file.len() + 1, + }, + ) { + let non_match_style = Style { + is_syntax_highlighted: true, + ..Style::new() + }; + + let match_style_sections = ansi::parse_style_sections(&raw_line[raw_code_start..]) + .iter() + .map(|(ansi_term_style, s)| { + if ansi_term_style.foreground.is_some() { + (match_style, *s) + } else { + (non_match_style, *s) + } + }) + .collect(); + Some(StyleSectionSpecifier::StyleSections(match_style_sections)) + } else { + None + } +} + +fn make_output_config() -> GrepOutputConfig { + match utils::parent_command_options() { + Some((longs, shorts)) if shorts.contains("-W") || longs.contains("--function-context") => { + // --function-context is in effect: i.e. the entire function is + // being displayed. In that case we don't render the first line as a + // header, since the second line is the true next line, and it will + // be more readable to have these displayed normally. We do add the + // navigate marker, since match lines will be surrounded by (many) + // non-match lines. And, since we are printing (many) successive lines + // of code, we pad line numbers <100 in order to maintain code + // alignment up to line 9999. + GrepOutputConfig { + render_context_header_as_hunk_header: false, + add_navigate_marker_to_matches: true, + pad_line_number: true, + } + } + Some((longs, shorts)) if shorts.contains("-p") || longs.contains("--show-function") => { + // --show-function is in effect, i.e. the function header is being + // displayed, along with matches within the function. Therefore we + // render the first line as a header, but we do not add the navigate + // marker, since all non-header lines are matches. + GrepOutputConfig { + render_context_header_as_hunk_header: true, + add_navigate_marker_to_matches: false, + pad_line_number: false, + } + } + _ => GrepOutputConfig { + render_context_header_as_hunk_header: true, + add_navigate_marker_to_matches: false, + pad_line_number: false, + }, + } +} + +#[derive(Debug, PartialEq)] +pub struct GrepLine<'a> { + pub file: &'a str, + pub line_number: Option, + pub line_type: LineType, + pub code: &'a str, +} + +#[derive(Debug, PartialEq)] +pub enum LineType { + ContextHeader, + Hit, + NoHit, +} + +// See tests for example grep lines +lazy_static! { + static ref GREP_LINE_REGEX: Regex = Regex::new( + r"(?x) +^ +(.+?\.[^-.=: ]+) # 1. file name (TODO: it must have an extension) +(?: + [-=:]([0-9]+) # 2. optional line number +)? +([-=:]) # 3. line-type marker +(.*) # 4. code (i.e. line contents) +$ +" + ) + .unwrap(); +} + +pub fn parse_git_grep_line(line: &str) -> Option { + let caps = GREP_LINE_REGEX.captures(line)?; + let file = caps.get(1).unwrap().as_str(); + let line_number = caps.get(2).map(|m| m.as_str().parse().ok()).flatten(); + let line_type = caps.get(3).map(|m| m.as_str()).try_into().ok()?; + let code = caps.get(4).unwrap().as_str(); + + Some(GrepLine { + file, + line_number, + line_type, + code, + }) +} + +impl TryFrom> for LineType { + type Error = (); + fn try_from(from: Option<&str>) -> Result { + match from { + Some(marker) if marker == "=" => Ok(LineType::ContextHeader), + Some(marker) if marker == ":" => Ok(LineType::Hit), + Some(marker) if marker == "-" => Ok(LineType::NoHit), + _ => Err(()), + } + } +} + +#[cfg(test)] +mod tests { + use crate::handlers::grep::{parse_git_grep_line, GrepLine, LineType}; + + #[test] + fn test_parse_grep_line() { + // git grep MinusPlus + assert_eq!( + parse_git_grep_line("src/config.rs:use crate::minusplus::MinusPlus;"), + Some(GrepLine { + file: "src/config.rs", + line_number: None, + line_type: LineType::Hit, + code: "use crate::minusplus::MinusPlus;", + }) + ); + + // git grep -n MinusPlus [with line numbers] + assert_eq!( + parse_git_grep_line("src/config.rs:21:use crate::minusplus::MinusPlus;"), + Some(GrepLine { + file: "src/config.rs", + line_number: Some(21), + line_type: LineType::Hit, + code: "use crate::minusplus::MinusPlus;", + }) + ); + + // git grep -W MinusPlus [with function context] + assert_eq!( + parse_git_grep_line("src/config.rs=pub struct Config {"), // match + Some(GrepLine { + file: "src/config.rs", + line_number: None, + line_type: LineType::ContextHeader, + code: "pub struct Config {", + }) + ); + assert_eq!( + parse_git_grep_line("src/config.rs- pub available_terminal_width: usize,"), + Some(GrepLine { + file: "src/config.rs", + line_number: None, + line_type: LineType::NoHit, + code: " pub available_terminal_width: usize,", + }) + ); + assert_eq!( + parse_git_grep_line( + "src/config.rs: pub line_numbers_style_minusplus: MinusPlus