From 1b194c36c15d0e47e486daa39b749aff7c6ee135 Mon Sep 17 00:00:00 2001 From: Dan Davison Date: Sun, 14 Nov 2021 14:15:51 -0500 Subject: [PATCH] Handle `git grep` output Fixes #769 --- src/delta.rs | 2 + src/handlers/file_meta.rs | 2 +- src/handlers/grep.rs | 372 ++++++++++++++++++++++++++++++++++++ src/handlers/hunk_header.rs | 2 +- src/handlers/mod.rs | 1 + src/paint.rs | 6 + 6 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/handlers/grep.rs 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..707f475e0 --- /dev/null +++ b/src/handlers/grep.rs @@ -0,0 +1,372 @@ +use std::convert::{TryFrom, TryInto}; + +use ansi_term; +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::{BgShouldFill, StyleSectionSpecifier}; +use crate::style::Style; +use crate::utils; + +struct GrepOutputConfig { + render_context_header_as_hunk_header: bool, + highlight_hits: 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, + )?, + _ => { + // Emit file & line-number + let hit_marker = match (&grep.line_type, output_config.highlight_hits) { + (LineType::Hit, true) => "• ", + (LineType::NoHit, true) => " ", + _ => "", + }; + let grep_line = match grep.line_number { + // We pad line numbers to a width of at least 3, so + // that we do not see any misalignment up to line + // number 999. However, see + // https://github.com/BurntSushi/ripgrep/issues/795 + // for discussion about aligning grep output. + Some(n) => { + format!("{}{}:{:<3}:", hit_marker, grep.file, n) + } + None => format!("{}{}:", hit_marker, grep.file), + }; + let style = self.config.file_style; + write!(self.painter.writer, "{}", style.paint(grep_line))?; + + // Emit code line + let code_style_sections = if matches!(&grep.line_type, LineType::Hit) + && output_config.highlight_hits + { + // 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.plus_style.ansi_term_style.background, + &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, + highlight_color: Option, + 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 hit_style_sections = ansi::parse_style_sections(&raw_line[raw_code_start..]) + .iter() + .map(|(ansi_term_style, s)| { + ( + Style { + ansi_term_style: ansi_term::Style { + // git highlights grep hits using foreground colors; + // move this to the background channel, leaving the + // foreground for our syntax highlighting. + foreground: None, + background: if ansi_term_style.foreground.is_some() { + highlight_color + } else { + None + }, + ..ansi_term::Style::new() + }, + is_syntax_highlighted: true, + ..Style::new() + }, + *s, + ) + }) + .collect(); + Some(StyleSectionSpecifier::StyleSections(hit_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 + // highlight hits, since these will be surrounded by non-hits. + GrepOutputConfig { + render_context_header_as_hunk_header: false, + highlight_hits: 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 hits within the function. + // Therefore we render the first line as a header, but we do not + // highlight hits, since all other lines are hits. + GrepOutputConfig { + render_context_header_as_hunk_header: true, + highlight_hits: true, + } + } + _ => GrepOutputConfig { + render_context_header_as_hunk_header: true, + highlight_hits: true, + }, + } +} + +#[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 {"), // hit + 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