summaryrefslogtreecommitdiffstats
path: root/crates/printer
diff options
context:
space:
mode:
authorLucas Trzesniewski <lucas.trzesniewski@gmail.com>2023-07-08 00:56:50 +0200
committerAndrew Gallant <jamslam@gmail.com>2023-09-25 14:39:54 -0400
commit1a50324013708e3c73bfa986d273af2f8e8e3360 (patch)
treeb638145e07f76f0d223997423dca6ac6199e25a9 /crates/printer
parent86ef6833085428c21ef1fb7f2de8e5e7f54f1f72 (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.toml2
-rw-r--r--crates/printer/src/counter.rs10
-rw-r--r--crates/printer/src/hyperlink.rs664
-rw-r--r--crates/printer/src/hyperlink_aliases.rs23
-rw-r--r--crates/printer/src/lib.rs6
-rw-r--r--crates/printer/src/standard.rs258
-rw-r--r--crates/printer/src/summary.rs73
-rw-r--r--crates/printer/src/util.rs52
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: &