Skip to content

Commit

Permalink
feat: Make browser links out of HTML file paths
Browse files Browse the repository at this point in the history
This provides an alternative to `--open`, where supported.

Fixes #12888
  • Loading branch information
epage committed Nov 1, 2023
1 parent d899b51 commit 39ef5e5
Show file tree
Hide file tree
Showing 9 changed files with 235 additions and 12 deletions.
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ serde_json = "1.0.108"
sha1 = "0.10.6"
sha2 = "0.10.8"
shell-escape = "0.1.5"
supports-hyperlinks = "2.1.0"
snapbox = { version = "0.4.14", features = ["diff", "path"] }
syn = { version = "2.0.38", features = ["extra-traits", "full"] }
tar = { version = "0.4.40", default-features = false }
Expand Down Expand Up @@ -173,6 +174,7 @@ serde_ignored.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
sha1.workspace = true
shell-escape.workspace = true
supports-hyperlinks.workspace = true
syn.workspace = true
tar.workspace = true
tempfile.workspace = true
Expand Down
23 changes: 13 additions & 10 deletions src/cargo/core/compiler/timings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,18 +339,21 @@ impl<'cfg> Timings<'cfg> {
include_str!("timings.js")
)?;
drop(f);
let msg = format!(
"report saved to {}",
std::env::current_dir()
.unwrap_or_default()
.join(&filename)
.display()
);

let unstamped_filename = timings_path.join("cargo-timing.html");
paths::link_or_copy(&filename, &unstamped_filename)?;
self.config
.shell()
.status_with_color("Timing", msg, &style::NOTE)?;

let mut shell = self.config.shell();
let timing_path = std::env::current_dir().unwrap_or_default().join(&filename);
let link = shell.err_file_hyperlink(&timing_path);
let msg = format!(
"report saved to {}{}{}",
link.open(),
timing_path.display(),
link.close()
);
shell.status_with_color("Timing", msg, &style::NOTE)?;

Ok(())
}

Expand Down
111 changes: 111 additions & 0 deletions src/cargo/core/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use anstream::AutoStream;
use anstyle::Style;

use crate::util::errors::CargoResult;
use crate::util::hostname;
use crate::util::style::*;

pub enum TtyWidth {
Expand Down Expand Up @@ -57,6 +58,7 @@ pub struct Shell {
/// Flag that indicates the current line needs to be cleared before
/// printing. Used when a progress bar is currently displayed.
needs_clear: bool,
hostname: Option<String>,
}

impl fmt::Debug for Shell {
Expand Down Expand Up @@ -85,6 +87,7 @@ enum ShellOut {
stderr: AutoStream<std::io::Stderr>,
stderr_tty: bool,
color_choice: ColorChoice,
hyperlinks: bool,
},
}

Expand All @@ -111,10 +114,12 @@ impl Shell {
stdout: AutoStream::new(std::io::stdout(), stdout_choice),
stderr: AutoStream::new(std::io::stderr(), stderr_choice),
color_choice: auto_clr,
hyperlinks: supports_hyperlinks(),
stderr_tty: std::io::stderr().is_terminal(),
},
verbosity: Verbosity::Verbose,
needs_clear: false,
hostname: None,
}
}

Expand All @@ -124,6 +129,7 @@ impl Shell {
output: ShellOut::Write(AutoStream::never(out)), // strip all formatting on write
verbosity: Verbosity::Verbose,
needs_clear: false,
hostname: None,
}
}

Expand Down Expand Up @@ -314,6 +320,16 @@ impl Shell {
Ok(())
}

pub fn set_hyperlinks(&mut self, yes: bool) -> CargoResult<()> {
if let ShellOut::Stream {
ref mut hyperlinks, ..
} = self.output
{
*hyperlinks = yes;
}
Ok(())
}

/// Gets the current color choice.
///
/// If we are not using a color stream, this will always return `Never`, even if the color
Expand All @@ -340,6 +356,63 @@ impl Shell {
}
}

pub fn out_hyperlink<D: fmt::Display>(&self, url: D) -> Hyperlink<D> {
let supports_hyperlinks = match &self.output {
ShellOut::Write(_) => false,
ShellOut::Stream {
stdout, hyperlinks, ..
} => stdout.current_choice() == anstream::ColorChoice::AlwaysAnsi && *hyperlinks,
};
if supports_hyperlinks {
Hyperlink { url: Some(url) }
} else {
Hyperlink { url: None }
}
}

pub fn err_hyperlink<D: fmt::Display>(&self, url: D) -> Hyperlink<D> {
let supports_hyperlinks = match &self.output {
ShellOut::Write(_) => false,
ShellOut::Stream {
stderr, hyperlinks, ..
} => stderr.current_choice() == anstream::ColorChoice::AlwaysAnsi && *hyperlinks,
};
if supports_hyperlinks {
Hyperlink { url: Some(url) }
} else {
Hyperlink { url: None }
}
}

pub fn out_file_hyperlink(&mut self, path: &std::path::Path) -> Hyperlink<url::Url> {
let url = self.file_hyperlink(path);
url.map(|u| self.out_hyperlink(u)).unwrap_or_default()
}

pub fn err_file_hyperlink(&mut self, path: &std::path::Path) -> Hyperlink<url::Url> {
let url = self.file_hyperlink(path);
url.map(|u| self.err_hyperlink(u)).unwrap_or_default()
}

fn file_hyperlink(&mut self, path: &std::path::Path) -> Option<url::Url> {
let mut url = url::Url::from_file_path(path).ok()?;
// Do a best-effort of setting the host in the URL to avoid issues with opening a link
// scoped to the computer you've SSHed into
let hostname = if cfg!(windows) {
// Not supported correctly on windows
None
} else {
if let Some(hostname) = self.hostname.as_deref() {
Some(hostname)
} else {
self.hostname = hostname().ok().and_then(|h| h.into_string().ok());
self.hostname.as_deref()
}
};
let _ = url.set_host(hostname);
Some(url)
}

/// Prints a message to stderr and translates ANSI escape code into console colors.
pub fn print_ansi_stderr(&mut self, message: &[u8]) -> CargoResult<()> {
if self.needs_clear {
Expand Down Expand Up @@ -439,6 +512,44 @@ fn supports_color(choice: anstream::ColorChoice) -> bool {
}
}

fn supports_hyperlinks() -> bool {
#[allow(clippy::disallowed_methods)] // We are reading the state of the system, not config
if std::env::var_os("TERM_PROGRAM").as_deref() == Some(std::ffi::OsStr::new("iTerm.app")) {
// Override `supports_hyperlinks` as we have an unknown incompatibility with iTerm2
return false;
}

::supports_hyperlinks::supports_hyperlinks()
}

pub struct Hyperlink<D: fmt::Display> {
url: Option<D>,
}

impl<D: fmt::Display> Default for Hyperlink<D> {
fn default() -> Self {
Self { url: None }
}
}

impl<D: fmt::Display> Hyperlink<D> {
pub fn open(&self) -> impl fmt::Display {
if let Some(url) = self.url.as_ref() {
itertools::Either::Left(format!("\x1B]8;;{url}\x1B\\"))
} else {
itertools::Either::Right("")
}
}

pub fn close(&self) -> impl fmt::Display {
if self.url.is_some() {
itertools::Either::Left("\x1B]8;;\x1B\\")
} else {
itertools::Either::Right("")
}
}
}

#[cfg(unix)]
mod imp {
use super::{Shell, TtyWidth};
Expand Down
12 changes: 10 additions & 2 deletions src/cargo/ops/cargo_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ pub fn doc(ws: &Workspace<'_>, options: &DocOptions) -> CargoResult<()> {
cfg.map(|path_args| (path_args.path.resolve_program(ws.config()), path_args.args))
};
let mut shell = ws.config().shell();
shell.status("Opening", path.display())?;
let link = shell.err_file_hyperlink(&path);
shell.status(
"Opening",
format!("{}{}{}", link.open(), path.display(), link.close()),
)?;
open_docs(&path, &mut shell, config_browser, ws.config())?;
}
} else {
Expand All @@ -47,7 +51,11 @@ pub fn doc(ws: &Workspace<'_>, options: &DocOptions) -> CargoResult<()> {
.join("index.html");
if path.exists() {
let mut shell = ws.config().shell();
shell.status("Generated", path.display())?;
let link = shell.err_file_hyperlink(&path);
shell.status(
"Generated",
format!("{}{}{}", link.open(), path.display(), link.close()),
)?;
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/cargo/util/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,9 @@ impl Config {

self.shell().set_verbosity(verbosity);
self.shell().set_color_choice(color)?;
if let Some(hyperlinks) = term.hyperlinks {
self.shell().set_hyperlinks(hyperlinks)?;
}
self.progress_config = term.progress.unwrap_or_default();
self.extra_verbose = extra_verbose;
self.frozen = frozen;
Expand Down Expand Up @@ -2560,6 +2563,7 @@ struct TermConfig {
verbose: Option<bool>,
quiet: Option<bool>,
color: Option<String>,
hyperlinks: Option<bool>,
#[serde(default)]
#[serde(deserialize_with = "progress_or_string")]
progress: Option<ProgressConfig>,
Expand Down
75 changes: 75 additions & 0 deletions src/cargo/util/hostname.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use std::{ffi::OsString, io};

/// Returns the hostname of the current system.
///
/// It is unusual, although technically possible, for this routine to return
/// an error. It is difficult to list out the error conditions, but one such
/// possibility is platform support.
///
/// # Platform specific behavior
///
/// On Unix, this returns the result of the `gethostname` function from the
/// `libc` linked into the program.
pub fn hostname() -> io::Result<OsString> {
#[cfg(unix)]
{
gethostname()
}
#[cfg(not(unix))]
{
Err(io::Error::new(
io::ErrorKind::Other,
"hostname could not be found on unsupported platform",
))
}
}

#[cfg(unix)]
fn gethostname() -> io::Result<OsString> {
use std::os::unix::ffi::OsStringExt;

// SAFETY: There don't appear to be any safety requirements for calling
// sysconf.
let limit = unsafe { libc::sysconf(libc::_SC_HOST_NAME_MAX) };
if limit == -1 {
// It is in theory possible for sysconf to return -1 for a limit but
// *not* set errno, in which case, io::Error::last_os_error is
// indeterminate. But untangling that is super annoying because std
// doesn't expose any unix-specific APIs for inspecting the errno. (We
// could do it ourselves, but it just doesn't seem worth doing?)
return Err(io::Error::last_os_error());
}
let Ok(maxlen) = usize::try_from(limit) else {
let msg = format!("host name max limit ({}) overflowed usize", limit);
return Err(io::Error::new(io::ErrorKind::Other, msg));
};
// maxlen here includes the NUL terminator.
let mut buf = vec![0; maxlen];
// SAFETY: The pointer we give is valid as it is derived directly from a
// Vec. Similarly, `maxlen` is the length of our Vec, and is thus valid
// to write to.
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast::<libc::c_char>(), maxlen) };
if rc == -1 {
return Err(io::Error::last_os_error());
}
// POSIX says that if the hostname is bigger than `maxlen`, then it may
// write a truncate name back that is not necessarily NUL terminated (wtf,
// lol). So if we can't find a NUL terminator, then just give up.
let Some(zeropos) = buf.iter().position(|&b| b == 0) else {
let msg = "could not find NUL terminator in hostname";
return Err(io::Error::new(io::ErrorKind::Other, msg));
};
buf.truncate(zeropos);
buf.shrink_to_fit();
Ok(OsString::from_vec(buf))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn print_hostname() {
println!("{:?}", hostname());
}
}
2 changes: 2 additions & 0 deletions src/cargo/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub use self::flock::{FileLock, Filesystem};
pub use self::graph::Graph;
pub use self::hasher::StableHasher;
pub use self::hex::{hash_u64, short_hash, to_hex};
pub use self::hostname::hostname;
pub use self::into_url::IntoUrl;
pub use self::into_url_with_base::IntoUrlWithBase;
pub(crate) use self::io::LimitErrorReader;
Expand Down Expand Up @@ -46,6 +47,7 @@ mod flock;
pub mod graph;
mod hasher;
pub mod hex;
mod hostname;
pub mod important_paths;
pub mod interning;
pub mod into_url;
Expand Down
Loading

0 comments on commit 39ef5e5

Please sign in to comment.