diff --git a/src/cli.rs b/src/cli.rs index eb1f9b857..193b06dfc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -402,12 +402,38 @@ pub struct Opt { /// See STYLES section. Defaults to hunk-header-file-path-style. pub grep_file_style: Option, + #[arg(long = "grep-header-decoration-style", value_name = "STYLE")] + /// Style string for the header decoration in grep output. + /// + /// Default is "none" when grep-ouput-type-is "ripgrep", otherwise defaults + /// to value of header-decoration-style. See hunk-header-decoration-style. + pub grep_header_decoration_style: Option, + + #[arg(long = "grep-header-file-style", value_name = "STYLE")] + /// Style string for the file path part of the header in grep output. + /// + /// See hunk_header_file_style. + pub grep_header_file_style: Option, + + #[arg(long = "grep-header-style", value_name = "STYLE")] + /// Style string for the header in grep output. + /// + /// See hunk-header-style. + pub grep_header_style: Option, + #[arg(long = "grep-line-number-style", value_name = "STYLE")] /// Style string for line numbers in grep output. /// /// See STYLES section. Defaults to hunk-header-line-number-style. pub grep_line_number_style: Option, + #[arg(long = "grep-output-type", value_name = "OUTPUT_TYPE")] + /// Grep output format. Possible values: + /// "ripgrep" - file name printed once, followed by matching lines within that file, each preceded by a line number. + /// "classic" - file name:line number, followed by matching line. + /// Default is "ripgrep" if `rg --json` format is detected, otherwise "classic". + pub grep_output_type: Option, + #[arg(long = "grep-match-line-style", value_name = "STYLE")] /// Style string for matching lines of grep output. /// diff --git a/src/config.rs b/src/config.rs index 10ab0ecac..f70c68d6f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,15 +65,20 @@ pub struct Config { pub git_plus_style: Style, pub grep_context_line_style: Style, pub grep_file_style: Style, + pub classic_grep_header_file_style: Style, + pub classic_grep_header_style: Style, + pub ripgrep_header_file_style: Style, + pub ripgrep_header_style: Style, pub grep_line_number_style: Style, pub grep_match_line_style: Style, pub grep_match_word_style: Style, + pub grep_output_type: Option, pub grep_separator_symbol: String, pub handle_merge_conflicts: bool, pub hunk_header_file_style: Style, pub hunk_header_line_number_style: Style, - pub hunk_header_style_include_file_path: bool, - pub hunk_header_style_include_line_number: bool, + pub hunk_header_style_include_file_path: HunkHeaderIncludeFilePath, + pub hunk_header_style_include_line_number: HunkHeaderIncludeLineNumber, pub hunk_header_style: Style, pub hunk_label: String, pub hyperlinks_commit_link_format: Option, @@ -129,6 +134,24 @@ pub struct Config { pub zero_style: Style, } +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum GrepType { + Ripgrep, + Classic, +} + +#[cfg_attr(test, derive(Clone))] +pub enum HunkHeaderIncludeFilePath { + Yes, + No, +} + +#[cfg_attr(test, derive(Clone))] +pub enum HunkHeaderIncludeLineNumber { + Yes, + No, +} + impl Config { pub fn get_style(&self, state: &State) -> &Style { match state { @@ -137,6 +160,7 @@ impl Config { State::HunkPlus(_, _) => &self.plus_style, State::CommitMeta => &self.commit_style, State::DiffHeader(_) => &self.file_style, + State::Grep(GrepType::Ripgrep, _) => &self.classic_grep_header_style, State::HunkHeader(_, _, _, _) => &self.hunk_header_style, State::SubmoduleLog => &self.file_style, _ => delta_unreachable("Unreachable code reached in get_style."), @@ -222,6 +246,13 @@ impl From for Config { opt.navigate_regex }; + let grep_output_type = match opt.grep_output_type.as_deref() { + Some("ripgrep") => Some(GrepType::Ripgrep), + Some("classic") => Some(GrepType::Classic), + None => None, + _ => fatal("Invalid option for grep-output-type: Expected \"ripgrep\" or \"classic\"."), + }; + #[cfg(not(test))] let cwd_of_delta_process = opt.env.current_dir; #[cfg(test)] @@ -271,22 +302,37 @@ impl From for Config { git_config: opt.git_config, grep_context_line_style: styles["grep-context-line-style"], grep_file_style: styles["grep-file-style"], + classic_grep_header_file_style: styles["classic-grep-header-file-style"], + classic_grep_header_style: styles["classic-grep-header-style"], + ripgrep_header_file_style: styles["ripgrep-header-file-style"], + ripgrep_header_style: styles["ripgrep-header-style"], grep_line_number_style: styles["grep-line-number-style"], grep_match_line_style: styles["grep-match-line-style"], grep_match_word_style: styles["grep-match-word-style"], + grep_output_type, grep_separator_symbol: opt.grep_separator_symbol, handle_merge_conflicts: !opt.raw, hunk_header_file_style: styles["hunk-header-file-style"], hunk_header_line_number_style: styles["hunk-header-line-number-style"], hunk_header_style: styles["hunk-header-style"], - hunk_header_style_include_file_path: opt + hunk_header_style_include_file_path: if opt .hunk_header_style .split(' ') - .any(|s| s == "file"), - hunk_header_style_include_line_number: opt + .any(|s| s == "file") + { + HunkHeaderIncludeFilePath::Yes + } else { + HunkHeaderIncludeFilePath::No + }, + hunk_header_style_include_line_number: if opt .hunk_header_style .split(' ') - .any(|s| s == "line-number"), + .any(|s| s == "line-number") + { + HunkHeaderIncludeLineNumber::Yes + } else { + HunkHeaderIncludeLineNumber::No + }, hyperlinks: opt.hyperlinks, hyperlinks_commit_link_format: opt.hyperlinks_commit_link_format, hyperlinks_file_link_format: opt.hyperlinks_file_link_format, diff --git a/src/delta.rs b/src/delta.rs index 1b0b2980b..8fbf3a651 100644 --- a/src/delta.rs +++ b/src/delta.rs @@ -8,6 +8,7 @@ use bytelines::ByteLines; use crate::ansi; use crate::config::delta_unreachable; use crate::config::Config; +use crate::config::GrepType; use crate::features; use crate::handlers::hunk_header::ParsedHunkHeader; use crate::handlers::{self, merge_conflict}; @@ -28,7 +29,7 @@ pub enum State { SubmoduleShort(String), // In a submodule section, with gitconfig diff.submodule = short Blame(String), // In a line of `git blame` output (key). GitShowFile, // In a line of `git show $revision:./path/to/file.ext` output - Grep, // In a line of `git grep` output + Grep(GrepType, String), // In a line of `git grep` output (path) Unknown, // The following elements are created when a line is wrapped to display it: HunkZeroWrapped, // Wrapped unchanged line diff --git a/src/features/hyperlinks.rs b/src/features/hyperlinks.rs index 4747c1311..f96922b48 100644 --- a/src/features/hyperlinks.rs +++ b/src/features/hyperlinks.rs @@ -332,6 +332,8 @@ __path__: some matching line "raw", "--grep-line-number-style", "raw", + "--grep-output-type", + "classic", "--hunk-header-file-style", "raw", "--hunk-header-line-number-style", diff --git a/src/handlers/grep.rs b/src/handlers/grep.rs index 553104dc6..54d6a644f 100644 --- a/src/handlers/grep.rs +++ b/src/handlers/grep.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::fmt::Write; use lazy_static::lazy_static; use regex::Regex; @@ -6,14 +7,20 @@ use serde::Deserialize; use unicode_segmentation::UnicodeSegmentation; use crate::ansi; +use crate::config::{ + delta_unreachable, GrepType, HunkHeaderIncludeFilePath, HunkHeaderIncludeLineNumber, +}; use crate::delta::{State, StateMachine}; use crate::handlers::{self, ripgrep_json}; use crate::paint::{self, expand_tabs, BgShouldFill, StyleSectionSpecifier}; use crate::style::Style; use crate::utils::process; +use super::hunk_header::HunkHeaderIncludeHunkLabel; + #[derive(Debug, PartialEq, Eq)] pub struct GrepLine<'b> { + pub grep_type: GrepType, pub path: Cow<'b, str>, pub line_number: Option, pub line_type: LineType, @@ -26,6 +33,7 @@ pub struct GrepLine<'b> { pub enum LineType { ContextHeader, Context, + FileHeader, Match, Ignore, } @@ -44,70 +52,191 @@ impl<'a> StateMachine<'a> { // If this is a line of git grep output then render it accordingly. pub fn handle_grep_line(&mut self) -> std::io::Result { self.painter.emit()?; - let mut handled_line = false; - let try_parse = matches!(&self.state, State::Grep | State::Unknown); + let (previous_path, try_parse) = match &self.state { + State::Grep(_, path) => (Some(path.clone()), true), + State::Unknown => (None, true), + _ => (None, false), + }; + let mut handled_line = false; if try_parse { let line = self.line.clone(); // TODO: avoid clone - if let Some(mut grep_line) = parse_grep_line(&line) { + if let Some(grep_line) = parse_grep_line(&line) { if matches!(grep_line.line_type, LineType::Ignore) { handled_line = true; return Ok(handled_line); } - - // Emit syntax-highlighted code - // TODO: Determine the language less frequently, e.g. only when the file changes. - if let Some(lang) = handlers::diff_header::get_extension(&grep_line.path) - .or(self.config.default_language.as_deref()) - { - self.painter.set_syntax(Some(lang)); - self.painter.set_highlighter(); - } - self.state = State::Grep; - - match ( - &grep_line.line_type, - OUTPUT_CONFIG.render_context_header_as_hunk_header, - ) { - // Emit context header line - (LineType::ContextHeader, true) => handlers::hunk_header::write_hunk_header( - &grep_line.code, - &[(grep_line.line_number.unwrap_or(0), 0)], - &mut self.painter, - &self.line, - &grep_line.path, - self.config, - )?, - _ => self._handle_grep_line(&mut grep_line)?, + self.state = State::Grep( + self.config + .grep_output_type + .clone() + .unwrap_or_else(|| grep_line.grep_type.clone()), + grep_line.path.to_string(), + ); + let first_path = previous_path.is_none(); + let new_path = first_path || previous_path.as_deref() != Some(&grep_line.path); + if new_path { + if let Some(lang) = handlers::diff_header::get_extension(&grep_line.path) + .or(self.config.default_language.as_deref()) + { + self.painter.set_syntax(Some(lang)); + self.painter.set_highlighter(); + } } + match &self.state { + State::Grep(GrepType::Ripgrep, _) => { + self.emit_ripgrep_format_grep_line(grep_line, new_path, first_path) + } + State::Grep(GrepType::Classic, _) => { + self.emit_classic_format_grep_line(grep_line) + } + _ => delta_unreachable("Impossible state while handling grep line."), + }?; handled_line = true } } Ok(handled_line) } - fn _handle_grep_line(&mut self, grep_line: &mut GrepLine) -> std::io::Result<()> { - if self.config.navigate { - write!( - self.painter.writer, - "{}", - match ( - &grep_line.line_type, - OUTPUT_CONFIG.add_navigate_marker_to_matches - ) { - (LineType::Match, true) => "• ", - (_, true) => " ", - _ => "", - } + // Emulate ripgrep output: each section of hits from the same path has a header line, + // and sections are separated by a blank line. Set language whenever path changes. + fn emit_ripgrep_format_grep_line( + &mut self, + mut grep_line: GrepLine, + new_path: bool, + first_path: bool, + ) -> std::io::Result<()> { + if new_path { + // Emit new path header line + if !first_path { + let _ = self.painter.output_buffer.write_char('\n'); + } + handlers::hunk_header::write_line_of_code_with_optional_path_and_line_number( + "", + &[(0, 0)], + None, + &mut self.painter, + &self.line, + &grep_line.path, + self.config.ripgrep_header_style.decoration_style, + &self.config.ripgrep_header_file_style, + &self.config.grep_line_number_style, + &HunkHeaderIncludeFilePath::Yes, + &HunkHeaderIncludeLineNumber::No, + &HunkHeaderIncludeHunkLabel::Yes, + self.config, )? } - self._emit_file_and_line_number(grep_line)?; - self._emit_code(grep_line)?; + // Emit the actual grep hit line + let code_style_sections = match (&grep_line.line_type, &grep_line.submatches) { + (LineType::Match, Some(submatches)) => { + // We expand tabs at this late stage because + // the tabs are escaped in the JSON, so + // expansion must come after JSON parsing. + // (At the time of writing, we are in this + // arm iff we are handling `ripgrep --json` + // output.) + grep_line.code = + paint::expand_tabs(grep_line.code.graphemes(true), self.config.tab_width) + .into(); + make_style_sections( + &grep_line.code, + submatches, + self.config.grep_match_word_style, + self.config.grep_match_line_style, + ) + } + (LineType::Match, None) => { + // HACK: We need tabs expanded, and we need + // the &str passed to + // `get_code_style_sections` to live long + // enough. But at this point it is guaranteed + // that this handler is going to handle this + // line, so mutating it is acceptable. + self.raw_line = expand_tabs(self.raw_line.graphemes(true), self.config.tab_width); + get_code_style_sections( + &self.raw_line, + self.config.grep_match_word_style, + self.config.grep_match_line_style, + &grep_line, + ) + .unwrap_or(StyleSectionSpecifier::Style( + self.config.grep_match_line_style, + )) + } + _ => StyleSectionSpecifier::Style(self.config.grep_context_line_style), + }; + handlers::hunk_header::write_line_of_code_with_optional_path_and_line_number( + &grep_line.code, + &[(grep_line.line_number.unwrap_or(0), 0)], + Some(code_style_sections), + &mut self.painter, + &self.line, + &grep_line.path, + crate::style::DecorationStyle::NoDecoration, + &self.config.grep_file_style, + &self.config.grep_line_number_style, + &HunkHeaderIncludeFilePath::No, + if grep_line.line_number.is_some() { + &HunkHeaderIncludeLineNumber::Yes + } else { + &HunkHeaderIncludeLineNumber::No + }, + &HunkHeaderIncludeHunkLabel::No, + self.config, + ) + } + + fn emit_classic_format_grep_line(&mut self, grep_line: GrepLine) -> std::io::Result<()> { + match ( + &grep_line.line_type, + OUTPUT_CONFIG.render_context_header_as_hunk_header, + ) { + // Emit context header line + (LineType::ContextHeader, true) => { + handlers::hunk_header::write_line_of_code_with_optional_path_and_line_number( + &grep_line.code, + &[(grep_line.line_number.unwrap_or(0), 0)], + None, + &mut self.painter, + &self.line, + &grep_line.path, + self.config.classic_grep_header_style.decoration_style, + &self.config.classic_grep_header_file_style, + &self.config.grep_line_number_style, + &self.config.hunk_header_style_include_file_path, + &self.config.hunk_header_style_include_line_number, + &HunkHeaderIncludeHunkLabel::Yes, + self.config, + )? + } + _ => { + if self.config.navigate { + write!( + self.painter.writer, + "{}", + match ( + &grep_line.line_type, + OUTPUT_CONFIG.add_navigate_marker_to_matches + ) { + (LineType::Match, true) => "• ", + (_, true) => " ", + _ => "", + } + )? + } + self._emit_classic_format_file_and_line_number(&grep_line)?; + self._emit_classic_format_code(grep_line)?; + } + } Ok(()) } - fn _emit_file_and_line_number(&mut self, grep_line: &GrepLine) -> std::io::Result<()> { + fn _emit_classic_format_file_and_line_number( + &mut self, + grep_line: &GrepLine, + ) -> std::io::Result<()> { let separator = if self.config.grep_separator_symbol == "keep" { // grep, rg, and git grep use ":" for matching lines // and "-" for non-matching lines (and `git grep -W` @@ -145,7 +274,7 @@ impl<'a> StateMachine<'a> { Ok(()) } - fn _emit_code(&mut self, grep_line: &mut GrepLine) -> std::io::Result<()> { + fn _emit_classic_format_code(&mut self, mut grep_line: GrepLine) -> std::io::Result<()> { let code_style_sections = match (&grep_line.line_type, &grep_line.submatches) { (LineType::Match, Some(submatches)) => { // We expand tabs at this late stage because @@ -176,7 +305,7 @@ impl<'a> StateMachine<'a> { &self.raw_line, self.config.grep_match_word_style, self.config.grep_match_line_style, - grep_line, + &grep_line, ) .unwrap_or(StyleSectionSpecifier::Style( self.config.grep_match_line_style, @@ -279,13 +408,13 @@ fn make_output_config() -> GrepOutputConfig { GrepOutputConfig { render_context_header_as_hunk_header: true, add_navigate_marker_to_matches: false, - pad_line_number: false, + pad_line_number: true, } } _ => GrepOutputConfig { render_context_header_as_hunk_header: true, add_navigate_marker_to_matches: false, - pad_line_number: false, + pad_line_number: true, }, } } @@ -349,7 +478,7 @@ fn make_grep_line_regex(regex_variant: GrepLineRegex) -> Regex { [^:|\ ] # try to be strict about what a file path can start with [^:]* # anything [^\ ]\.[^.\ :=-]{1,10} # extension - ) + ) " } GrepLineRegex::WithFileExtensionNoSpaces => { @@ -357,7 +486,7 @@ fn make_grep_line_regex(regex_variant: GrepLineRegex) -> Regex { ( # 1. file name (colons not allowed) [^:|\ ]+ # try to be strict about what a file path can start with [^\ ]\.[^.\ :=-]{1,6} # extension - ) + ) " } GrepLineRegex::WithoutSeparatorCharacters => { @@ -366,7 +495,7 @@ fn make_grep_line_regex(regex_variant: GrepLineRegex) -> Regex { [^:|\ =-] # try to be strict about what a file path can start with [^:=-]* # anything except separators [^:\ ] # a file name cannot end with whitespace - ) + ) " } }; @@ -395,7 +524,7 @@ fn make_grep_line_regex(regex_variant: GrepLineRegex) -> Regex { _ => { r#" (?: - ( + ( : # 2. match marker (?:([0-9]+):)? # 3. optional: line number followed by second match marker ) @@ -465,6 +594,7 @@ pub fn _parse_grep_line<'b>(regex: &Regex, line: &'b str) -> Option let code = caps.get(8).unwrap().as_str().into(); Some(GrepLine { + grep_type: GrepType::Classic, path: file, line_number: *line_number, line_type: *line_type, @@ -475,7 +605,7 @@ pub fn _parse_grep_line<'b>(regex: &Regex, line: &'b str) -> Option #[cfg(test)] mod tests { - use crate::handlers::grep::{parse_grep_line, GrepLine, LineType}; + use crate::handlers::grep::{parse_grep_line, GrepLine, GrepType, LineType}; use crate::utils::process::tests::FakeParentArgs; #[test] @@ -486,6 +616,7 @@ mod tests { assert_eq!( parse_grep_line("src/co-7-fig.rs:xxx"), Some(GrepLine { + grep_type: GrepType::Classic, path: "src/co-7-fig.rs".into(), line_number: None, line_type: LineType::Match, @@ -497,6 +628,7 @@ mod tests { assert_eq!( parse_grep_line("src/config.rs:use crate::minusplus::MinusPlus;"), Some(GrepLine { + grep_type: GrepType::Classic, path: "src/config.rs".into(), line_number: None, line_type: LineType::Match, @@ -510,6 +642,7 @@ mod tests { "src/config.rs: pub line_numbers_style_minusplus: MinusPlus