From 1a50324013708e3c73bfa986d273af2f8e8e3360 Mon Sep 17 00:00:00 2001 From: Lucas Trzesniewski Date: Sat, 8 Jul 2023 00:56:50 +0200 Subject: [PATCH] printer: add hyperlinks This commit represents the initial work to get hyperlinks working and was submitted as part of PR #2483. Subsequent commits largely retain the functionality and structure of the hyperlink support added here, but rejigger some things around. --- .gitignore | 1 + Cargo.lock | 69 +++ complete/_rg | 1 + crates/cli/src/wtr.rs | 21 + crates/core/app.rs | 21 + crates/core/args.rs | 19 +- crates/core/path_printer.rs | 40 +- crates/printer/Cargo.toml | 2 + crates/printer/src/counter.rs | 10 +- crates/printer/src/hyperlink.rs | 664 ++++++++++++++++++++++++ crates/printer/src/hyperlink_aliases.rs | 23 + crates/printer/src/lib.rs | 6 + crates/printer/src/standard.rs | 258 ++++++--- crates/printer/src/summary.rs | 73 ++- crates/printer/src/util.rs | 52 +- tests/regression.rs | 1 + 16 files changed, 1178 insertions(+), 83 deletions(-) create mode 100644 crates/printer/src/hyperlink.rs create mode 100644 crates/printer/src/hyperlink_aliases.rs diff --git a/.gitignore b/.gitignore index aefc2c0e..881633e2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ target /termcolor/Cargo.lock /wincolor/Cargo.lock /deployment +/.idea # Snapcraft files stage diff --git a/Cargo.lock b/Cargo.lock index d6fd4508..0527cc8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,16 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets", +] + [[package]] name = "glob" version = "0.3.1" @@ -208,9 +218,11 @@ version = "0.1.7" dependencies = [ "base64", "bstr", + "gethostname", "grep-matcher", "grep-regex", "grep-searcher", + "lazy_static", "serde", "serde_json", "termcolor", @@ -612,3 +624,60 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/complete/_rg b/complete/_rg index 657648ca..be8d18ba 100644 --- a/complete/_rg +++ b/complete/_rg @@ -305,6 +305,7 @@ _rg() { '--debug[show debug messages]' '--field-context-separator[set string to delimit fields in context lines]' '--field-match-separator[set string to delimit fields in matching lines]' + '--hyperlink-format=[specify pattern for hyperlinks]:pattern' '--trace[show more verbose debug messages]' '--dfa-size-limit=[specify upper size limit of generated DFA]:DFA size (bytes)' "(1 stats)--files[show each file that would be searched (but don't search)]" diff --git a/crates/cli/src/wtr.rs b/crates/cli/src/wtr.rs index b4890364..b6755d1d 100644 --- a/crates/cli/src/wtr.rs +++ b/crates/cli/src/wtr.rs @@ -1,6 +1,7 @@ use std::io; use termcolor; +use termcolor::HyperlinkSpec; use crate::is_tty_stdout; @@ -101,6 +102,16 @@ impl termcolor::WriteColor for StandardStream { } } + #[inline] + fn supports_hyperlinks(&self) -> bool { + use self::StandardStreamKind::*; + + match self.0 { + LineBuffered(ref w) => w.supports_hyperlinks(), + BlockBuffered(ref w) => w.supports_hyperlinks(), + } + } + #[inline] fn set_color(&mut self, spec: &termcolor::ColorSpec) -> io::Result<()> { use self::StandardStreamKind::*; @@ -111,6 +122,16 @@ impl termcolor::WriteColor for StandardStream { } } + #[inline] + fn set_hyperlink(&mut self, link: &HyperlinkSpec) -> io::Result<()> { + use self::StandardStreamKind::*; + + match self.0 { + LineBuffered(ref mut w) => w.set_hyperlink(link), + BlockBuffered(ref mut w) => w.set_hyperlink(link), + } + } + #[inline] fn reset(&mut self) -> io::Result<()> { use self::StandardStreamKind::*; diff --git a/crates/core/app.rs b/crates/core/app.rs index 875c880b..9c523479 100644 --- a/crates/core/app.rs +++ b/crates/core/app.rs @@ -580,6 +580,7 @@ pub fn all_args_and_flags() -> Vec { flag_glob_case_insensitive(&mut args); flag_heading(&mut args); flag_hidden(&mut args); + flag_hyperlink_format(&mut args); flag_iglob(&mut args); flag_ignore_case(&mut args); flag_ignore_file(&mut args); @@ -1494,6 +1495,26 @@ This flag can be disabled with --no-hidden. args.push(arg); } +fn flag_hyperlink_format(args: &mut Vec) { + const SHORT: &str = "Set the format of hyperlinks to match results."; + const LONG: &str = long!( + "\ +Set the format of hyperlinks to match results. This defines a pattern which +can contain the following placeholders: {file}, {line}, {column}, and {host}. +An empty pattern or 'none' disables hyperlinks. + +The {file} placeholder is required, and will be replaced with the absolute +file path with a few adjustments: The leading '/' on Unix is removed, +and '\\' is replaced with '/' on Windows. + +As an example, the default pattern on Unix systems is: 'file://{host}/{file}' +" + ); + let arg = + RGArg::flag("hyperlink-format", "FORMAT").help(SHORT).long_help(LONG); + args.push(arg); +} + fn flag_iglob(args: &mut Vec) { const SHORT: &str = "Include or exclude files case insensitively."; const LONG: &str = long!( diff --git a/crates/core/args.rs b/crates/core/args.rs index dc4cadb8..f3af1dab 100644 --- a/crates/core/args.rs +++ b/crates/core/args.rs @@ -5,6 +5,7 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process; +use std::str::FromStr; use std::sync::Arc; use std::time::SystemTime; @@ -17,8 +18,8 @@ use grep::pcre2::{ RegexMatcherBuilder as PCRE2RegexMatcherBuilder, }; use grep::printer::{ - default_color_specs, ColorSpecs, JSONBuilder, Standard, StandardBuilder, - Stats, Summary, SummaryBuilder, SummaryKind, JSON, + default_color_specs, ColorSpecs, HyperlinkPattern, JSONBuilder, Standard, + StandardBuilder, Stats, Summary, SummaryBuilder, SummaryKind, JSON, }; use grep::regex::{ RegexMatcher as RustRegexMatcher, @@ -235,6 +236,7 @@ impl Args { let mut builder = PathPrinterBuilder::new(); builder .color_specs(self.matches().color_specs()?) + .hyperlink_pattern(self.matches().hyperlink_pattern()?) .separator(self.matches().path_separator()?) .terminator(self.matches().path_terminator().unwrap_or(b'\n')); Ok(builder.build(wtr)) @@ -772,6 +774,7 @@ impl ArgMatches { let mut builder = StandardBuilder::new(); builder .color_specs(self.color_specs()?) + .hyperlink_pattern(self.hyperlink_pattern()?) .stats(self.stats()) .heading(self.heading()) .path(self.with_filename(paths)) @@ -811,6 +814,7 @@ impl ArgMatches { builder .kind(self.summary_kind().expect("summary format")) .color_specs(self.color_specs()?) + .hyperlink_pattern(self.hyperlink_pattern()?) .stats(self.stats()) .path(self.with_filename(paths)) .max_matches(self.max_count()?) @@ -1118,6 +1122,17 @@ impl ArgMatches { self.is_present("hidden") || self.unrestricted_count() >= 2 } + /// Returns the hyperlink pattern to use. A default pattern suitable + /// for the current system is used if the value is not set. + /// + /// If an invalid pattern is provided, then an error is returned. + fn hyperlink_pattern(&self) -> Result { + Ok(match self.value_of_lossy("hyperlink-format") { + Some(pattern) => HyperlinkPattern::from_str(&pattern)?, + None => HyperlinkPattern::default_file_scheme(), + }) + } + /// Returns true if ignore files should be processed case insensitively. fn ignore_file_case_insensitive(&self) -> bool { self.is_present("ignore-file-case-insensitive") diff --git a/crates/core/path_printer.rs b/crates/core/path_printer.rs index b964a84a..44b624ad 100644 --- a/crates/core/path_printer.rs +++ b/crates/core/path_printer.rs @@ -1,13 +1,16 @@ use std::io; use std::path::Path; -use grep::printer::{ColorSpecs, PrinterPath}; +use grep::printer::{ + ColorSpecs, HyperlinkPattern, HyperlinkSpan, PrinterPath, +}; use termcolor::WriteColor; /// A configuration for describing how paths should be written. #[derive(Clone, Debug)] struct Config { colors: ColorSpecs, + hyperlink_pattern: HyperlinkPattern, separator: Option, terminator: u8, } @@ -16,6 +19,7 @@ impl Default for Config { fn default() -> Config { Config { colors: ColorSpecs::default(), + hyperlink_pattern: HyperlinkPattern::default(), separator: None, terminator: b'\n', } @@ -37,7 +41,7 @@ impl PathPrinterBuilder { /// Create a new path printer with the current configuration that writes /// paths to the given writer. pub fn build(&self, wtr: W) -> PathPrinter { - PathPrinter { config: self.config.clone(), wtr } + PathPrinter { config: self.config.clone(), wtr, buf: vec![] } } /// Set the color specification for this printer. @@ -52,6 +56,17 @@ impl PathPrinterBuilder { self } + /// Set the hyperlink pattern to use for hyperlinks output by this printer. + /// + /// Colors need to be enabled for hyperlinks to be output. + pub fn hyperlink_pattern( + &mut self, + pattern: HyperlinkPattern, + ) -> &mut PathPrinterBuilder { + self.config.hyperlink_pattern = pattern; + self + } + /// A path separator. /// /// When provided, the path's default separator will be replaced with @@ -80,6 +95,7 @@ impl PathPrinterBuilder { pub struct PathPrinter { config: Config, wtr: W, + buf: Vec, } impl PathPrinter { @@ -89,10 +105,30 @@ impl PathPrinter { if !self.wtr.supports_color() { self.wtr.write_all(ppath.as_bytes())?; } else { + let mut hyperlink = self.start_hyperlink_span(&ppath)?; self.wtr.set_color(self.config.colors.path())?; self.wtr.write_all(ppath.as_bytes())?; self.wtr.reset()?; + hyperlink.end(&mut self.wtr)?; } self.wtr.write_all(&[self.config.terminator]) } + + /// Starts a hyperlink span when applicable. + fn start_hyperlink_span( + &mut self, + path: &PrinterPath, + ) -> io::Result { + if self.wtr.supports_hyperlinks() { + if let Some(spec) = path.create_hyperlink_spec( + &self.config.hyperlink_pattern, + None, + None, + &mut self.buf, + ) { + return Ok(HyperlinkSpan::start(&mut self.wtr, &spec)?); + } + } + Ok(HyperlinkSpan::default()) + } } diff --git a/crates/printer/Cargo.toml b/crates/printer/Cargo.toml index e557d08d..2536a235 100644 --- a/crates/printer/Cargo.toml +++ b/crates/printer/Cargo.toml @@ -21,8 +21,10 @@ serde1 = ["base64", "serde", "serde_json"] [dependencies] base64 = { version = "0.20.0", optional = true } bstr = "1.6.0" +gethostname = "0.4.3" grep-matcher = { version = "0.1.6", path = "../matcher" } grep-searcher = { version = "0.1.11", path = "../searcher" } +lazy_static = "1.1.0" termcolor = "1.0.4" serde = { version = "1.0.77", optional = true, features = ["derive"] } serde_json = { version = "1.0.27", optional = true } diff --git a/crates/printer/src/counter.rs b/crates/printer/src/counter.rs index c2faac83..9df9c3df 100644 --- a/crates/printer/src/counter.rs +++ b/crates/printer/src/counter.rs @@ -1,6 +1,6 @@ use std::io::{self, Write}; -use termcolor::{ColorSpec, WriteColor}; +use termcolor::{ColorSpec, HyperlinkSpec, WriteColor}; /// A writer that counts the number of bytes that have been successfully /// written. @@ -76,10 +76,18 @@ impl WriteColor for CounterWriter { self.wtr.supports_color() } + fn supports_hyperlinks(&self) -> bool { + self.wtr.supports_hyperlinks() + } + fn set_color(&mut self, spec: &ColorSpec) -> io::Result<()> { self.wtr.set_color(spec) } + fn set_hyperlink(&mut self, link: &HyperlinkSpec) -> io::Result<()> { + self.wtr.set_hyperlink(link) + } + fn reset(&mut self) -> io::Result<()> { self.wtr.reset() } diff --git a/crates/printer/src/hyperlink.rs b/crates/printer/src/hyperlink.rs new file mode 100644 index 00000000..eebdba4a --- /dev/null +++ b/crates/printer/src/hyperlink.rs @@ -0,0 +1,664 @@ +use crate::hyperlink_aliases::HYPERLINK_PATTERN_ALIASES; +use bstr::ByteSlice; +use std::error::Error; +use std::fmt::Display; +use std::io; +use std::io::Write; +use std::path::Path; +use std::str::FromStr; +use termcolor::{HyperlinkSpec, WriteColor}; + +/// A builder for `HyperlinkPattern`. +/// +/// Once a `HyperlinkPattern` is built, it is immutable. +#[derive(Debug)] +pub struct HyperlinkPatternBuilder { + parts: Vec, +} + +/// A hyperlink pattern with placeholders. +/// +/// This can be created with `HyperlinkPatternBuilder` or from a string +/// using `HyperlinkPattern::from_str`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct HyperlinkPattern { + parts: Vec, + is_line_dependent: bool, +} + +/// A hyperlink pattern part. +#[derive(Clone, Debug, Eq, PartialEq)] +enum Part { + /// Static text. Can include invariant values such as the hostname. + Text(Vec), + /// Placeholder for the file path. + File, + /// Placeholder for the line number. + Line, + /// Placeholder for the column number. + Column, +} + +/// An error that can occur when parsing a hyperlink pattern. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HyperlinkPatternError { + /// This occurs when the pattern syntax is not valid. + InvalidSyntax, + /// This occurs when the {file} placeholder is missing. + NoFilePlaceholder, + /// This occurs when the {line} placeholder is missing, + /// while the {column} placeholder is present. + NoLinePlaceholder, + /// This occurs when an unknown placeholder is used. + InvalidPlaceholder(String), + /// The pattern doesn't start with a valid scheme. + InvalidScheme, +} + +/// The values to replace the pattern placeholders with. +#[derive(Clone, Debug)] +pub struct HyperlinkValues<'a> { + file: &'a HyperlinkPath, + line: u64, + column: u64, +} + +/// Represents the {file} part of a hyperlink. +/// +/// This is the value to use as-is in the hyperlink, converted from an OS file path. +#[derive(Clone, Debug)] +pub struct HyperlinkPath(Vec); + +impl HyperlinkPatternBuilder { + /// Creates a new hyperlink pattern builder. + pub fn new() -> Self { + Self { parts: vec![] } + } + + /// Appends static text. + pub fn append_text(&mut self, text: &[u8]) -> &mut Self { + if let Some(Part::Text(contents)) = self.parts.last_mut() { + contents.extend_from_slice(text); + } else if !text.is_empty() { + self.parts.push(Part::Text(text.to_vec())); + } + self + } + + /// Appends the hostname. + /// + /// On WSL, appends `wsl$/{distro}` instead. + pub fn append_hostname(&mut self) -> &mut Self { + self.append_text(Self::get_hostname().as_bytes()) + } + + /// Returns the hostname to use in the pattern. + /// + /// On WSL, returns `wsl$/{distro}`. + fn get_hostname() -> String { + if cfg!(unix) { + if let Ok(mut wsl_distro) = std::env::var("WSL_DISTRO_NAME") { + wsl_distro.insert_str(0, "wsl$/"); + return wsl_distro; + } + } + + gethostname::gethostname().to_string_lossy().to_string() + } + + /// Appends a placeholder for the file path. + pub fn append_file(&mut self) -> &mut Self { + self.parts.push(Part::File); + self + } + + /// Appends a placeholder for the line number. + pub fn append_line(&mut self) -> &mut Self { + self.parts.push(Part::Line); + self + } + + /// Appends a placeholder for the column number. + pub fn append_column(&mut self) -> &mut Self { + self.parts.push(Part::Column); + self + } + + /// Builds the pattern. + pub fn build(&self) -> Result { + self.validate()?; + + Ok(HyperlinkPattern { + parts: self.parts.clone(), + is_line_dependent: self.parts.contains(&Part::Line), + }) + } + + /// Validate that the pattern is well-formed. + fn validate(&self) -> Result<(), HyperlinkPatternError> { + if self.parts.is_empty() { + return Ok(()); + } + + if !self.parts.contains(&Part::File) { + return Err(HyperlinkPatternError::NoFilePlaceholder); + } + + if self.parts.contains(&Part::Column) + && !self.parts.contains(&Part::Line) + { + return Err(HyperlinkPatternError::NoLinePlaceholder); + } + + self.validate_scheme() + } + + /// Validate that the pattern starts with a valid scheme. + /// + /// A valid scheme starts with an alphabetic character, continues with + /// a sequence of alphanumeric characters, periods, hyphens or plus signs, + /// and ends with a colon. + fn validate_scheme(&self) -> Result<(), HyperlinkPatternError> { + if let Some(Part::Text(value)) = self.parts.first() { + if let Some(colon_index) = value.find_byte(b':') { + if value[0].is_ascii_alphabetic() + && value.iter().take(colon_index).all(|c| { + c.is_ascii_alphanumeric() + || matches!(c, b'.' | b'-' | b'+') + }) + { + return Ok(()); + } + } + } + + Err(HyperlinkPatternError::InvalidScheme) + } +} + +impl HyperlinkPattern { + /// Creates an empty hyperlink pattern. + pub fn empty() -> Self { + HyperlinkPattern::default() + } + + /// Creates a default pattern suitable for Unix. + /// + /// The returned pattern is `file://{host}/{file}` + #[cfg(unix)] + pub fn default_file_scheme() -> Self { + HyperlinkPatternBuilder::new() + .append_text(b"file://") + .append_hostname() + .append_text(b"/") + .append_file() + .build() + .unwrap() + } + + /// Creates a default pattern suitable for Windows. + /// + /// The returned pattern is `file:///{file}` + #[cfg(windows)] + pub fn default_file_scheme() -> Self { + HyperlinkPatternBuilder::new() + .append_text(b"file:///") + .append_file() + .build() + .unwrap() + } + + /// Returns true if this pattern is empty. + pub fn is_empty(&self) -> bool { + self.parts.is_empty() + } + + /// Returns true if the pattern can produce line-dependent hyperlinks. + pub fn is_line_dependent(&self) -> bool { + self.is_line_dependent + } + + /// Renders this pattern with the given values to the given output. + pub fn render( + &self, + values: &HyperlinkValues, + output: &mut impl Write, + ) -> io::Result<()> { + for part in &self.parts { + part.render(values, output)?; + } + Ok(()) + } +} + +impl FromStr for HyperlinkPattern { + type Err = HyperlinkPatternError; + + fn from_str(s: &str) -> Result { + let mut builder = HyperlinkPatternBuilder::new(); + let mut input = s.as_bytes(); + + if let Ok(index) = HYPERLINK_PATTERN_ALIASES + .binary_search_by_key(&input, |&(name, _)| name.as_bytes()) + { + input = HYPERLINK_PATTERN_ALIASES[index].1.as_bytes(); + } + + while !input.is_empty() { + if input[0] == b'{' { + // Placeholder + let end = input + .find_byte(b'}') + .ok_or(HyperlinkPatternError::InvalidSyntax)?; + + match &input[1..end] { + b"file" => builder.append_file(), + b"line" => builder.append_line(), + b"column" => builder.append_column(), + b"host" => builder.append_hostname(), + other => { + return Err(HyperlinkPatternError::InvalidPlaceholder( + String::from_utf8_lossy(other).to_string(), + )) + } + }; + + input = &input[(end + 1)..]; + } else { + // Static text + let end = input.find_byte(b'{').unwrap_or(input.len()); + builder.append_text(&input[..end]); + input = &input[end..]; + } + } + + builder.build() + } +} + +impl ToString for HyperlinkPattern { + fn to_string(&self) -> String { + self.parts.iter().map(|p| p.to_string()).collect() + } +} + +impl Part { + fn render( + &self, + values: &HyperlinkValues, + output: &mut impl Write, + ) -> io::Result<()> { + match self { + Part::Text(text) => output.write_all(text), + Part::File => output.write_all(&values.file.0), + Part::Line => write!(output, "{}", values.line), + Part::Column => write!(output, "{}", values.column), + } + } +} + +impl ToString for Part { + fn to_string(&self) -> String { + match self { + Part::Text(text) => String::from_utf8_lossy(text).to_string(), + Part::File => "{file}".to_string(), + Part::Line => "{line}".to_string(), + Part::Column => "{column}".to_string(), + } + } +} + +impl Display for HyperlinkPatternError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HyperlinkPatternError::InvalidSyntax => { + write!(f, "invalid hyperlink pattern syntax") + } + HyperlinkPatternError::NoFilePlaceholder => { + write!(f, "the {{file}} placeholder is required in hyperlink patterns") + } + HyperlinkPatternError::NoLinePlaceholder => { + write!(f, "the hyperlink pattern contains a {{column}} placeholder, \ + but no {{line}} placeholder is present") + } + HyperlinkPatternError::InvalidPlaceholder(name) => { + write!( + f, + "invalid hyperlink pattern placeholder: '{}', choose from: \ + file, line, column, host", + name + ) + } + HyperlinkPatternError::InvalidScheme => { + write!( + f, + "the hyperlink pattern must start with a valid URL scheme" + ) + } + } + } +} + +impl Error for HyperlinkPatternError {} + +impl<'a> HyperlinkValues<'a> { + /// Creates a new set of hyperlink values. + pub fn new( + file: &'a HyperlinkPath, + line: Option, + column: Option, + ) -> Self { + HyperlinkValues { + file, + line: line.unwrap_or(1), + column: column.unwrap_or(1), + } + } +} + +impl HyperlinkPath { + /// Returns a hyperlink path from an OS path. + #[cfg(unix)] + pub fn from_path(path: &Path) -> Option { + // On Unix, this function returns the absolute file path without the leading slash, + // as it makes for more natural hyperlink patterns, for instance: + // file://{host}/{file} instead of file://{host}{file} + // vscode://file/{file} instead of vscode://file{file} + // It also allows for patterns to be multi-platform. + + let path = path.canonicalize().ok()?; + let path = path.to_str()?.as_bytes(); + let path = if path.starts_with(b"/") { &path[1..] } else { path }; + Some(Self::encode(path)) + } + + /// Returns a hyperlink path from an OS path. + #[cfg(windows)] + pub fn from_path(path: &Path) -> Option { + // On Windows, Path::canonicalize returns the result of + // GetFinalPathNameByHandleW with VOLUME_NAME_DOS, + // which produces paths such as the following: + // \\?\C:\dir\file.txt (local path) + // \\?\UNC\server\dir\file.txt (network share) + // + // The \\?\ prefix comes from VOLUME_NAME_DOS and is constant. + // It is followed either by the drive letter, or by UNC\ + // (universal naming convention), which denotes a network share. + // + // Given that the default URL pattern on Windows is file:///{file} + // we need to return the following from this function: + // C:/dir/file.txt (local path) + // /server/dir/file.txt (network share) + // + // Which produces the following links: + // file:///C:/dir/file.txt (local path) + // file:////server/dir/file.txt (network share) + // + // This substitutes the {file} placeholder with the expected value + // for the most common DOS paths, but on the other hand, + // network paths start with a single slash, which may be unexpected. + // It produces correct URLs though. + // + // Note that the following URL syntax is also valid for network shares: + // file://server/dir/file.txt + // It is also more consistent with the Unix case, but in order to + // use it, the pattern would have to be file://{file} and + // the {file} placeholder would have to be replaced with + // /C:/dir/file.txt + // for local files, which is not ideal, and it is certainly unexpected. + // + // Also note that the file://C:/dir/file.txt syntax is not correct, + // even though it often works in practice. + // + // In the end, this choice was confirmed by VSCode, whose pattern + // is vscode://file/{file}:{line}:{column} and which correctly understands + // the following URL format for network drives: + // vscode://file//server/dir/file.txt:1:1 + // It doesn't parse any other number of slashes in "file//server" as a network path. + + const WIN32_NAMESPACE_PREFIX: &[u8] = br"\\?\"; + const UNC_PREFIX: &[u8] = br"UNC\"; + + let path = path.canonicalize().ok()?; + let mut path = path.to_str()?.as_bytes(); + + if path.starts_with(WIN32_NAMESPACE_PREFIX) { + path = &path[WIN32_NAMESPACE_PREFIX.len()..]; + + if path.starts_with(UNC_PREFIX) { + path = &path[(UNC_PREFIX.len() - 1)..]; + } + } else { + return None; + } + + Some(Self::encode(path)) + } + + /// Percent-encodes a path. + /// + /// The alphanumeric ASCII characters and "-", ".", "_", "~" are unreserved + /// as per section 2.3 of RFC 3986 (Uniform Resource Identifier (URI): Generic Syntax), + /// and are not encoded. The other ASCII characters except "/" and ":" are percent-encoded, + /// and "\" is replaced by "/" on Windows. + /// + /// Section 4 of RFC 8089 (The "file" URI Scheme) does not mandate precise encoding + /// requirements for non-ASCII characters, and this implementation leaves them unencoded. + /// On Windows, the UrlCreateFromPathW function does not encode non-ASCII characters. + /// Doing so with UTF-8 encoded paths creates invalid file:// URLs on that platform. + fn encode(input: &[u8]) -> HyperlinkPath { + let mut result = Vec::with_capacity(input.len()); + + for &c in input { + match c { + b'0'..=b'9' + | b'A'..=b'Z' + | b'a'..=b'z' + | b'/' + | b':' + | b'-' + | b'.' + | b'_' + | b'~' + | 128.. => { + result.push(c); + } + #[cfg(windows)] + b'\\' => { + result.push(b'/'); + } + _ => { + const HEX: &[u8] = b"0123456789ABCDEF"; + result.push(b'%'); + result.push(HEX[(c >> 4) as usize]); + result.push(HEX[(c & 0xF) as usize]); + } + } + } + + Self(result) + } +} + +impl Display for HyperlinkPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + std::str::from_utf8(&self.0).unwrap_or("invalid utf-8") + ) + } +} + +/// A simple abstraction over a hyperlink span written to the terminal. +/// This helps tracking whether a hyperlink has been started, and should be ended. +#[derive(Debug, Default)] +pub struct HyperlinkSpan { + active: bool, +} + +impl HyperlinkSpan { + /// Starts a hyperlink and returns a span which tracks whether it is still in effect. + pub fn start( + wtr: &mut impl WriteColor, + hyperlink: &HyperlinkSpec, + ) -> io::Result { + if wtr.supports_hyperlinks() && hyperlink.uri().is_some() { + wtr.set_hyperlink(hyperlink)?; + Ok(HyperlinkSpan { active: true }) + } else { + Ok(HyperlinkSpan { active: false }) + } + } + + /// Ends the hyperlink span if it is active. + pub fn end(&mut self, wtr: &mut impl WriteColor) -> io::Result<()> { + if self.is_active() { + wtr.set_hyperlink(&HyperlinkSpec::close())?; + self.active = false; + } + Ok(()) + } + + /// Returns true if there is currently an active hyperlink. + pub fn is_active(&self) -> bool { + self.active + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_pattern() { + let pattern = HyperlinkPatternBuilder::new() + .append_text(b"foo://") + .append_text(b"bar-") + .append_text(b"baz") + .append_file() + .build() + .unwrap(); + + assert_eq!(pattern.to_string(), "foo://bar-baz{file}"); + assert_eq!(pattern.parts[0], Part::Text(b"foo://bar-baz".to_vec())); + assert!(!pattern.is_empty()); + } + + #[test] + fn build_empty_pattern() { + let pattern = HyperlinkPatternBuilder::new().build().unwrap(); + + assert!(pattern.is_empty()); + assert_eq!(pattern, HyperlinkPattern::empty()); + assert_eq!(pattern, HyperlinkPattern::default()); + } + + #[test] + fn handle_alias() { + assert!(HyperlinkPattern::from_str("file").is_ok()); + assert!(HyperlinkPattern::from_str("none").is_ok()); + assert!(HyperlinkPattern::from_str("none").unwrap().is_empty()); + } + + #[test] + fn parse_pattern() { + let pattern = HyperlinkPattern::from_str( + "foo://{host}/bar/{file}:{line}:{column}", + ) + .unwrap(); + + assert_eq!( + pattern.to_string(), + "foo://{host}/bar/{file}:{line}:{column}" + .replace("{host}", &HyperlinkPatternBuilder::get_hostname()) + ); + assert_eq!(pattern.parts.len(), 6); + assert!(pattern.parts.contains(&Part::File)); + assert!(pattern.parts.contains(&Part::Line)); + assert!(pattern.parts.contains(&Part::Column)); + } + + #[test] + fn parse_valid() { + assert!(HyperlinkPattern::from_str("").unwrap().is_empty()); + assert_eq!( + HyperlinkPattern::from_str("foo://{file}").unwrap().to_string(), + "foo://{file}" + ); + assert_eq!( + HyperlinkPattern::from_str("foo://{file}/bar") + .unwrap() + .to_string(), + "foo://{file}/bar" + ); + + HyperlinkPattern::from_str("f://{file}").unwrap(); + HyperlinkPattern::from_str("f:{file}").unwrap(); + HyperlinkPattern::from_str("f-+.:{file}").unwrap(); + HyperlinkPattern::from_str("f42:{file}").unwrap(); + } + + #[test] + fn parse_invalid() { + assert_eq!( + HyperlinkPattern::from_str("foo://bar").unwrap_err(), + HyperlinkPatternError::NoFilePlaceholder + ); + assert_eq!( + HyperlinkPattern::from_str("foo://{bar}").unwrap_err(), + HyperlinkPatternError::InvalidPlaceholder("bar".to_string()) + ); + assert_eq!( + HyperlinkPattern::from_str("foo://{file").unwrap_err(), + HyperlinkPatternError::InvalidSyntax + ); + assert_eq!( + HyperlinkPattern::from_str("foo://{file}:{column}").unwrap_err(), + HyperlinkPatternError::NoLinePlaceholder + ); + assert_eq!( + HyperlinkPattern::from_str("{file}").unwrap_err(), + HyperlinkPatternError::InvalidScheme + ); + assert_eq!( + HyperlinkPattern::from_str(":{file}").unwrap_err(), + HyperlinkPatternError::InvalidScheme + ); + assert_eq!( + HyperlinkPattern::from_str("f*:{file}").unwrap_err(), + HyperlinkPatternError::InvalidScheme + ); + } + + #[test] + fn aliases_are_valid() { + for (name, definition) in HYPERLINK_PATTERN_ALIASES { + assert!( + HyperlinkPattern::from_str(definition).is_ok(), + "invalid hyperlink alias: {}", + name + ); + } + } + + #[test] + fn aliases_are_sorted() { + let mut names = HYPERLINK_PATTERN_ALIASES.iter().map(|(name, _)| name); + + let Some(mut previous_name) = names.next() else { + return; + }; + + for name in names { + assert!( + name > previous_name, + r#""{}" should be sorted before "{}" in `HYPERLINK_PATTERN_ALIASES`"#, + name, + previous_name + ); + + previous_name = name; + } + } +} diff --git a/crates/printer/src/hyperlink_aliases.rs b/crates/printer/src/hyperlink_aliases.rs new file mode 100644 index 00000000..139e982b --- /dev/null +++ b/crates/printer/src/hyperlink_aliases.rs @@ -0,0 +1,23 @@ +/// Aliases to well-known hyperlink schemes. +/// +/// These need to be sorted by name. +pub const HYPERLINK_PATTERN_ALIASES: &[(&str, &str)] = &[ + #[cfg(unix)] + ("file", "file://{host}/{file}"), + #[cfg(windows)] + ("file", "file:///{file}"), + // https://github.com/misaki-web/grepp + ("grep+", "grep+:///{file}:{line}"), + ("kitty", "file://{host}/{file}#{line}"), + // https://macvim.org/docs/gui_mac.txt.html#mvim%3A%2F%2F + ("macvim", "mvim://open?url=file:///{file}&line={line}&column={column}"), + ("none", ""), + // https://github.com/inopinatus/sublime_url + ("subl", "subl://open?url=file:///{file}&line={line}&column={column}"), + // https://macromates.com/blog/2007/the-textmate-url-scheme/ + ("textmate", "txmt://open?url=file:///{file}&line={line}&column={column}"), + // https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls + ("vscode", "vscode://file/{file}:{line}:{column}"), + ("vscode-insiders", "vscode-insiders://file/{file}:{line}:{column}"), + ("vscodium", "vscodium://file/{file}:{line}:{column}"), +]; diff --git a/crates/printer/src/lib.rs b/crates/printer/src/lib.rs index 29e0a45b..e0093fe9 100644 --- a/crates/printer/src/lib.rs +++ b/crates/printer/src/lib.rs @@ -67,6 +67,10 @@ fn example() -> Result<(), Box> { pub use crate::color::{ default_color_specs, ColorError, ColorSpecs, UserColorSpec, }; +pub use crate::hyperlink::{ + HyperlinkPath, HyperlinkPattern, HyperlinkPatternError, HyperlinkSpan, + HyperlinkValues, +}; #[cfg(feature = "serde1")] pub use crate::json::{JSONBuilder, JSONSink, JSON}; pub use crate::standard::{Standard, StandardBuilder, StandardSink}; @@ -90,6 +94,8 @@ mod macros; mod color; mod counter; +mod hyperlink; +mod hyperlink_aliases; #[cfg(feature = "serde1")] mod json; #[cfg(feature = "serde1")] diff --git a/crates/printer/src/standard.rs b/crates/printer/src/standard.rs index ab887c1e..ac4338a9 100644 --- a/crates/printer/src/standard.rs +++ b/crates/printer/src/standard.rs @@ -15,6 +15,7 @@ use termcolor::{ColorSpec, NoColor, WriteColor}; use crate::color::ColorSpecs; use crate::counter::CounterWriter; +use crate::hyperlink::{HyperlinkPattern, HyperlinkSpan}; use crate::stats::Stats; use crate::util::{ find_iter_at_in_context, trim_ascii_prefix, trim_line_terminator, @@ -29,6 +30,7 @@ use crate::util::{ #[derive(Debug, Clone)] struct Config { colors: ColorSpecs, + hyperlink_pattern: HyperlinkPattern, stats: bool, heading: bool, path: bool, @@ -54,6 +56,7 @@ impl Default for Config { fn default() -> Config { Config { colors: ColorSpecs::default(), + hyperlink_pattern: HyperlinkPattern::default(), stats: false, heading: false, path: true, @@ -122,6 +125,7 @@ impl StandardBuilder { Standard { config: self.config.clone(), wtr: RefCell::new(CounterWriter::new(wtr)), + buf: RefCell::new(vec![]), matches: vec![], } } @@ -160,6 +164,17 @@ impl StandardBuilder { self } + /// Set the hyperlink pattern to use for hyperlinks output by this printer. + /// + /// Colors need to be enabled for hyperlinks to be output. + pub fn hyperlink_pattern( + &mut self, + pattern: HyperlinkPattern, + ) -> &mut StandardBuilder { + self.config.hyperlink_pattern = pattern; + self + } + /// Enable the gathering of various aggregate statistics. /// /// When this is enabled (it's disabled by default), statistics will be @@ -467,6 +482,7 @@ impl StandardBuilder { pub struct Standard { config: Config, wtr: RefCell>, + buf: RefCell>, matches: Vec, } @@ -1209,23 +1225,25 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { line_number: Option, column: Option, ) -> io::Result<()> { - let sep = self.separator_field(); + let mut prelude = PreludeWriter::new(self); + prelude.start(line_number, column)?; if !self.config().heading { - self.write_path_field(sep)?; + prelude.write_path()?; } if let Some(n) = line_number { - self.write_line_number(n, sep)?; + prelude.write_line_number(n)?; } if let Some(n) = column { if self.config().column { - self.write_column_number(n, sep)?; + prelude.write_column_number(n)?; } } if self.config().byte_offset { - self.write_byte_offset(absolute_byte_offset, sep)?; + prelude.write_byte_offset(absolute_byte_offset)?; } - Ok(()) + + prelude.end() } #[inline(always)] @@ -1386,7 +1404,7 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { /// terminator.) fn write_path_line(&self) -> io::Result<()> { if let Some(path) = self.path() { - self.write_spec(self.config().colors.path(), path.as_bytes())?; + self.write_path_hyperlink(path)?; if let Some(term) = self.config().path_terminator { self.write(&[term])?; } else { @@ -1396,22 +1414,6 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { Ok(()) } - /// If this printer has a file path associated with it, then this will - /// write that path to the underlying writer followed by the given field - /// separator. (If a path terminator is set, then that is used instead of - /// the field separator.) - fn write_path_field(&self, field_separator: &[u8]) -> io::Result<()> { - if let Some(path) = self.path() { - self.write_spec(self.config().colors.path(), path.as_bytes())?; - if let Some(term) = self.config().path_terminator { - self.write(&[term])?; - } else { - self.write(field_separator)?; - } - } - Ok(()) - } - fn write_search_prelude(&self) -> io::Result<()> { let this_search_written = self.wtr().borrow().count() > 0; if this_search_written { @@ -1438,7 +1440,7 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { let bin = self.searcher.binary_detection(); if let Some(byte) = bin.quit_byte() { if let Some(path) = self.path() { - self.write_spec(self.config().colors.path(), path.as_bytes())?; + self.write_path_hyperlink(path)?; self.write(b": ")?; } let remainder = format!( @@ -1450,7 +1452,7 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { self.write(remainder.as_bytes())?; } else if let Some(byte) = bin.convert_byte() { if let Some(path) = self.path() { - self.write_spec(self.config().colors.path(), path.as_bytes())?; + self.write_path_hyperlink(path)?; self.write(b": ")?; } let remainder = format!( @@ -1471,39 +1473,6 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { Ok(()) } - fn write_line_number( - &self, - line_number: u64, - field_separator: &[u8], - ) -> io::Result<()> { - let n = line_number.to_string(); - self.write_spec(self.config().colors.line(), n.as_bytes())?; - self.write(field_separator)?; - Ok(()) - } - - fn write_column_number( - &self, - column_number: u64, - field_separator: &[u8], - ) -> io::Result<()> { - let n = column_number.to_string(); - self.write_spec(self.config().colors.column(), n.as_bytes())?; - self.write(field_separator)?; - Ok(()) - } - - fn write_byte_offset( - &self, - offset: u64, - field_separator: &[u8], - ) -> io::Result<()> { - let n = offset.to_string(); - self.write_spec(self.config().colors.column(), n.as_bytes())?; - self.write(field_separator)?; - Ok(()) - } - fn write_line_term(&self) -> io::Result<()> { self.write(self.searcher.line_terminator().as_bytes()) } @@ -1516,6 +1485,40 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { Ok(()) } + fn write_path(&self, path: &PrinterPath) -> io::Result<()> { + let mut wtr = self.wtr().borrow_mut(); + wtr.set_color(self.config().colors.path())?; + wtr.write_all(path.as_bytes())?; + wtr.reset() + } + + fn write_path_hyperlink(&self, path: &PrinterPath) -> io::Result<()> { + let mut hyperlink = self.start_hyperlink_span(path, None, None)?; + self.write_path(path)?; + hyperlink.end(&mut *self.wtr().borrow_mut()) + } + + fn start_hyperlink_span( + &self, + path: &PrinterPath, + line_number: Option, + column: Option, + ) -> io::Result { + let mut wtr = self.wtr().borrow_mut(); + if wtr.supports_hyperlinks() { + let mut buf = self.buf().borrow_mut(); + if let Some(spec) = path.create_hyperlink_spec( + &self.config().hyperlink_pattern, + line_number, + column, + &mut buf, + ) { + return HyperlinkSpan::start(&mut *wtr, &spec); + } + } + Ok(HyperlinkSpan::default()) + } + fn start_color_match(&self) -> io::Result<()> { if self.in_color_match.get() { return Ok(()); @@ -1569,6 +1572,12 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { &self.sink.standard.wtr } + /// Return a temporary buffer, which may be used for anything. + /// It is not necessarily empty when returned. + fn buf(&self) -> &'a RefCell> { + &self.sink.standard.buf + } + /// Return the path associated with this printer, if one exists. fn path(&self) -> Option<&'a PrinterPath<'a>> { self.sink.path.as_ref() @@ -1615,6 +1624,139 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { } } +/// A writer for the prelude (the beginning part of a matching line). +/// +/// This encapsulates the state needed to print the prelude. +struct PreludeWriter<'a, M: Matcher, W> { + std: &'a StandardImpl<'a, M, W>, + next_separator: PreludeSeparator, + field_separator: &'a [u8], + hyperlink: HyperlinkSpan, +} + +/// A type of separator used in the prelude +enum PreludeSeparator { + /// No separator. + None, + /// The field separator, either for a matching or contextual line. + FieldSeparator, + /// The path terminator. + PathTerminator, +} + +impl<'a, M: Matcher, W: WriteColor> PreludeWriter<'a, M, W> { + /// Creates a new prelude printer. + fn new(std: &'a StandardImpl<'a, M, W>) -> PreludeWriter<'a, M, W> { + Self { + std, + next_separator: PreludeSeparator::None, + field_separator: std.separator_field(), + hyperlink: HyperlinkSpan::default(), + } + } + + /// Starts the prelude with a hyperlink when applicable. + /// + /// If a heading was written, and the hyperlink pattern is invariant on the line number, + /// then this doesn't hyperlink each line prelude, as it wouldn't point to the line anyway. + /// The hyperlink on the heading should be sufficient and less confusing. + fn start( + &mut self, + line_number: Option, + column: Option, + ) -> io::Result<()> { + if let Some(path) = self.std.path() { + if self.config().hyperlink_pattern.is_line_dependent() + || !self.config().heading + { + self.hyperlink = self.std.start_hyperlink_span( + path, + line_number, + column, + )?; + } + } + Ok(()) + } + + /// Ends the prelude and writes the remaining output. + fn end(&mut self) -> io::Result<()> { + if self.hyperlink.is_active() { + self.hyperlink.end(&mut *self.std.wtr().borrow_mut())?; + } + self.write_separator() + } + + /// If this printer has a file path associated with it, then this will + /// write that path to the underlying writer followed by the given field + /// separator. (If a path terminator is set, then that is used instead of + /// the field separator.) + fn write_path(&mut self) -> io::Result<()> { + if let Some(path) = self.std.path() { + self.write_separator()?; + self.std.write_path(path)?; + + self.next_separator = if self.config().path_terminator.is_some() { + PreludeSeparator::PathTerminator + } else { + PreludeSeparator::FieldSeparator + }; + } + Ok(()) + } + + /// Writes the line number field. + fn write_line_number(&mut self, line_number: u64) -> io::Result<()> { + self.write_separator()?; + let n = line_number.to_string(); + self.std.write_spec(self.config().colors.line(), n.as_bytes())?; + self.next_separator = PreludeSeparator::FieldSeparator; + Ok(()) + } + + /// Writes the column number field. + fn write_column_number(&mut self, column_number: u64) -> io::Result<()> { + self.write_separator()?; + let n = column_number.to_string(); + self.std.write_spec(self.config().colors.column(), n.as_bytes())?; + self.next_separator = PreludeSeparator::FieldSeparator; + Ok(()) + } + + /// Writes the byte offset field. + fn write_byte_offset(&mut self, offset: u64) -> io::Result<()> { + self.write_separator()?; + let n = offset.to_string(); + self.std.write_spec(self.config().colors.column(), n.as_bytes())?; + self.next_separator = PreludeSeparator::FieldSeparator; + Ok(()) + } + + /// Writes the separator defined by the preceding field. + /// + /// This is called before writing the contents of a field, and at + /// the end of the prelude. + fn write_separator(&mut self) -> io::Result<()> { + match self.next_separator { + PreludeSeparator::None => {} + PreludeSeparator::FieldSeparator => { + self.std.write(self.field_separator)?; + } + PreludeSeparator::PathTerminator => { + if let Some(term) = self.config().path_terminator { + self.std.write(&[term])?; + } + } + } + self.next_separator = PreludeSeparator::None; + Ok(()) + } + + fn config(&self) -> &Config { + self.std.config() + } +} + #[cfg(test)] mod tests { use grep_matcher::LineTerminator; diff --git a/crates/printer/src/summary.rs b/crates/printer/src/summary.rs index b1adb904..99e3a1dc 100644 --- a/crates/printer/src/summary.rs +++ b/crates/printer/src/summary.rs @@ -10,6 +10,7 @@ use termcolor::{ColorSpec, NoColor, WriteColor}; use crate::color::ColorSpecs; use crate::counter::CounterWriter; +use crate::hyperlink::{HyperlinkPattern, HyperlinkSpan}; use crate::stats::Stats; use crate::util::{find_iter_at_in_context, PrinterPath}; @@ -22,6 +23,7 @@ use crate::util::{find_iter_at_in_context, PrinterPath}; struct Config { kind: SummaryKind, colors: ColorSpecs, + hyperlink_pattern: HyperlinkPattern, stats: bool, path: bool, max_matches: Option, @@ -36,6 +38,7 @@ impl Default for Config { Config { kind: SummaryKind::Count, colors: ColorSpecs::default(), + hyperlink_pattern: HyperlinkPattern::default(), stats: false, path: true, max_matches: None, @@ -160,6 +163,7 @@ impl SummaryBuilder { Summary { config: self.config.clone(), wtr: RefCell::new(CounterWriter::new(wtr)), + buf: vec![], } } @@ -206,6 +210,17 @@ impl SummaryBuilder { self } + /// Set the hyperlink pattern to use for hyperlinks output by this printer. + /// + /// Colors need to be enabled for hyperlinks to be output. + pub fn hyperlink_pattern( + &mut self, + pattern: HyperlinkPattern, + ) -> &mut SummaryBuilder { + self.config.hyperlink_pattern = pattern; + self + } + /// Enable the gathering of various aggregate statistics. /// /// When this is enabled (it's disabled by default), statistics will be @@ -328,6 +343,7 @@ impl SummaryBuilder { pub struct Summary { config: Config, wtr: RefCell>, + buf: Vec, } impl Summary { @@ -532,12 +548,9 @@ impl<'p, 's, M: Matcher, W: WriteColor> SummarySink<'p, 's, M, W> { /// write that path to the underlying writer followed by a line terminator. /// (If a path terminator is set, then that is used instead of the line /// terminator.) - fn write_path_line(&self, searcher: &Searcher) -> io::Result<()> { - if let Some(ref path) = self.path { - self.write_spec( - self.summary.config.colors.path(), - path.as_bytes(), - )?; + fn write_path_line(&mut self, searcher: &Searcher) -> io::Result<()> { + if self.path.is_some() { + self.write_path()?; if let Some(term) = self.summary.config.path_terminator { self.write(&[term])?; } else { @@ -551,12 +564,9 @@ impl<'p, 's, M: Matcher, W: WriteColor> SummarySink<'p, 's, M, W> { /// write that path to the underlying writer followed by the field /// separator. (If a path terminator is set, then that is used instead of /// the field separator.) - fn write_path_field(&self) -> io::Result<()> { - if let Some(ref path) = self.path { - self.write_spec( - self.summary.config.colors.path(), - path.as_bytes(), - )?; + fn write_path_field(&mut self) -> io::Result<()> { + if self.path.is_some() { + self.write_path()?; if let Some(term) = self.summary.config.path_terminator { self.write(&[term])?; } else { @@ -566,6 +576,43 @@ impl<'p, 's, M: Matcher, W: WriteColor> SummarySink<'p, 's, M, W> { Ok(()) } + /// If this printer has a file path associated with it, then this will + /// write that path to the underlying writer in the appropriate style + /// (color and hyperlink). + fn write_path(&mut self) -> io::Result<()> { + if self.path.is_some() { + let mut hyperlink = self.start_hyperlink_span()?; + + self.write_spec( + self.summary.config.colors.path(), + self.path.as_ref().unwrap().as_bytes(), + )?; + + if hyperlink.is_active() { + hyperlink.end(&mut *self.summary.wtr.borrow_mut())?; + } + } + Ok(()) + } + + /// Starts a hyperlink span when applicable. + fn start_hyperlink_span(&mut self) -> io::Result { + if let Some(ref path) = self.path { + let mut wtr = self.summary.wtr.borrow_mut(); + if wtr.supports_hyperlinks() { + if let Some(spec) = path.create_hyperlink_spec( + &self.summary.config.hyperlink_pattern, + None, + None, + &mut self.summary.buf, + ) { + return Ok(HyperlinkSpan::start(&mut *wtr, &spec)?); + } + } + } + Ok(HyperlinkSpan::default()) + } + /// Write the line terminator configured on the given searcher. fn write_line_term(&self, searcher: &Searcher) -> io::Result<()> { self.write(searcher.line_terminator().as_bytes()) @@ -704,11 +751,11 @@ impl<'p, 's, M: Matcher, W: WriteColor> Sink for SummarySink<'p, 's, M, W> { } SummaryKind::CountMatches => { if show_count { + self.write_path_field()?; let stats = self .stats .as_ref() .expect("CountMatches should enable stats tracking"); - self.write_path_field()?; self.write(stats.matches().to_string().as_bytes())?; self.write_line_term(searcher)?; } diff --git a/crates/printer/src/util.rs b/crates/printer/src/util.rs index 73a29964..d987421d 100644 --- a/crates/printer/src/util.rs +++ b/crates/printer/src/util.rs @@ -1,8 +1,8 @@ use std::borrow::Cow; -use std::fmt; -use std::io; +use std::cell::OnceCell; use std::path::Path; use std::time; +use std::{fmt, io}; use bstr::{ByteSlice, ByteVec}; use grep_matcher::{Captures, LineTerminator, Match, Matcher}; @@ -11,7 +11,9 @@ use grep_searcher::{ }; #[cfg(feature = "serde1")] use serde::{Serialize, Serializer}; +use termcolor::HyperlinkSpec; +use crate::hyperlink::{HyperlinkPath, HyperlinkPattern, HyperlinkValues}; use crate::MAX_LOOK_AHEAD; /// A type for handling replacements while amortizing allocation. @@ -276,12 +278,20 @@ impl<'a> Sunk<'a> { /// portability with a small cost: on Windows, paths that are not valid UTF-16 /// will not roundtrip correctly. #[derive(Clone, Debug)] -pub struct PrinterPath<'a>(Cow<'a, [u8]>); +pub struct PrinterPath<'a> { + path: &'a Path, + bytes: Cow<'a, [u8]>, + hyperlink_path: OnceCell>, +} impl<'a> PrinterPath<'a> { /// Create a new path suitable for printing. pub fn new(path: &'a Path) -> PrinterPath<'a> { - PrinterPath(Vec::from_path_lossy(path)) + PrinterPath { + path, + bytes: Vec::from_path_lossy(path), + hyperlink_path: OnceCell::new(), + } } /// Create a new printer path from the given path which can be efficiently @@ -303,7 +313,7 @@ impl<'a> PrinterPath<'a> { /// environments, only `/` is treated as a path separator. fn replace_separator(&mut self, new_sep: u8) { let transformed_path: Vec = self - .0 + .as_bytes() .bytes() .map(|b| { if b == b'/' || (cfg!(windows) && b == b'\\') { @@ -313,12 +323,40 @@ impl<'a> PrinterPath<'a> { } }) .collect(); - self.0 = Cow::Owned(transformed_path); + self.bytes = Cow::Owned(transformed_path); } /// Return the raw bytes for this path. pub fn as_bytes(&self) -> &[u8] { - &self.0 + &self.bytes + } + + /// Creates a hyperlink for this path and the given line and column, using the specified + /// pattern. Uses the given buffer to store the hyperlink. + pub fn create_hyperlink_spec<'b>( + &self, + pattern: &HyperlinkPattern, + line_number: Option, + column: Option, + buffer: &'b mut Vec, + ) -> Option> { + if pattern.is_empty() { + return None; + } + let file_path = self.hyperlink_path()?; + let values = HyperlinkValues::new(file_path, line_number, column); + buffer.clear(); + pattern.render(&values, buffer).ok()?; + Some(HyperlinkSpec::open(buffer)) + } + + /// Returns the file path to use in hyperlinks, if any. + /// + /// This is what the {file} placeholder will be substituted with. + fn hyperlink_path(&self) -> Option<&HyperlinkPath> { + self.hyperlink_path + .get_or_init(|| HyperlinkPath::from_path(self.path)) + .as_ref() } } diff --git a/tests/regression.rs b/tests/regression.rs index 91c37449..24551fc0 100644 --- a/tests/regression.rs +++ b/tests/regression.rs @@ -380,6 +380,7 @@ rgtest!(r428_color_context_path, |dir: Dir, mut cmd: TestCommand| { "-N", "--colors=match:none", "--color=always", + "--hyperlink-format=", "foo", ]);