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.
This commit is contained in:
Lucas Trzesniewski
2023-07-08 00:56:50 +02:00
committed by Andrew Gallant
parent 86ef683308
commit 1a50324013
16 changed files with 1178 additions and 83 deletions

View File

@@ -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 }

View File

@@ -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()
}

View File

@@ -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;
}
}
}

View File

@@ -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}"),
];

View File

@@ -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")]

View File

@@ -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: &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<u64>,
column: Option<u64>,
) -> io::Result<HyperlinkSpan> {
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<Vec<u8>> {
&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<u64>,
column: Option<u64>,
) -> 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;

View File

@@ -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<u64>,
@@ -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<W> {
config: Config,
wtr: RefCell<CounterWriter<W>>,
buf: Vec<u8>,
}
impl<W: WriteColor> Summary<W> {
@@ -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<HyperlinkSpan> {
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)?;
}

View File

@@ -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<Option<HyperlinkPath>>,
}
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<u8> = 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<u64>,
column: Option<u64>,
buffer: &'b mut Vec<u8>,
) -> Option<HyperlinkSpec<'b>> {
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()
}
}