diff options
author | Lucas Trzesniewski <lucas.trzesniewski@gmail.com> | 2023-07-08 00:56:50 +0200 |
---|---|---|
committer | Andrew Gallant <jamslam@gmail.com> | 2023-09-25 14:39:54 -0400 |
commit | 1a50324013708e3c73bfa986d273af2f8e8e3360 (patch) | |
tree | b638145e07f76f0d223997423dca6ac6199e25a9 /crates/printer | |
parent | 86ef6833085428c21ef1fb7f2de8e5e7f54f1f72 (diff) |
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.
Diffstat (limited to 'crates/printer')
-rw-r--r-- | crates/printer/Cargo.toml | 2 | ||||
-rw-r--r-- | crates/printer/src/counter.rs | 10 | ||||
-rw-r--r-- | crates/printer/src/hyperlink.rs | 664 | ||||
-rw-r--r-- | crates/printer/src/hyperlink_aliases.rs | 23 | ||||
-rw-r--r-- | crates/printer/src/lib.rs | 6 | ||||
-rw-r--r-- | crates/printer/src/standard.rs | 258 | ||||
-rw-r--r-- | crates/printer/src/summary.rs | 73 | ||||
-rw-r--r-- | crates/printer/src/util.rs | 52 |
8 files changed, 1009 insertions, 79 deletions
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<W: WriteColor> WriteColor for CounterWriter<W> { 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<Part>, +} + +/// 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<Part>, + 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<u8>), + /// 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<u8>); + +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<HyperlinkPattern, HyperlinkPatternError> { + 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<Self, Self::Err> { + 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<u64>, + column: Option<u64>, + ) -> 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<Self> { + // 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<Self> { + // 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<Self> { + 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<Error>> { 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<W> { config: Config, wtr: RefCell<CounterWriter<W>>, + buf: RefCell<Vec<u8>>, matches: Vec<Match>, } @@ -1209,23 +1225,25 @@ impl<'a, M: Matcher, W: WriteColor> StandardImpl<'a, M, W> { line_number: Option<u64>, column: Option<u64>, ) -> 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: & |