diff --git a/Cargo.lock b/Cargo.lock index be4bf64714a0..a195616f552c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,7 @@ dependencies = [ "sha1", "shell-escape", "snapbox", + "supports-hyperlinks", "syn 2.0.37", "tar", "tempfile", @@ -3104,6 +3105,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index ae75a3561808..d18d208b268c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,7 @@ serde_json = "1.0.107" sha1 = "0.10.6" sha2 = "0.10.8" shell-escape = "0.1.5" +supports-hyperlinks = "2.1.0" snapbox = { version = "0.4.13", features = ["diff", "path"] } syn = { version = "2.0.37", features = ["extra-traits", "full"] } tar = { version = "0.4.40", default-features = false } @@ -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 diff --git a/src/cargo/core/compiler/timings.rs b/src/cargo/core/compiler/timings.rs index 7c388bd103b7..c7d37d8393f2 100644 --- a/src/cargo/core/compiler/timings.rs +++ b/src/cargo/core/compiler/timings.rs @@ -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(()) } diff --git a/src/cargo/core/shell.rs b/src/cargo/core/shell.rs index 768c86f7a91a..15aa4c067fa1 100644 --- a/src/cargo/core/shell.rs +++ b/src/cargo/core/shell.rs @@ -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 { @@ -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, } impl fmt::Debug for Shell { @@ -85,6 +87,7 @@ enum ShellOut { stderr: AutoStream, stderr_tty: bool, color_choice: ColorChoice, + hyperlinks: bool, }, } @@ -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, } } @@ -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, } } @@ -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 @@ -340,6 +356,63 @@ impl Shell { } } + pub fn out_hyperlink(&self, url: D) -> Hyperlink { + 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(&self, url: D) -> Hyperlink { + 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 { + 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 { + 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 { + 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 { @@ -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 { + url: Option, +} + +impl Default for Hyperlink { + fn default() -> Self { + Self { url: None } + } +} + +impl Hyperlink { + 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}; diff --git a/src/cargo/ops/cargo_doc.rs b/src/cargo/ops/cargo_doc.rs index 3b735c84ed43..ecc17e9fc463 100644 --- a/src/cargo/ops/cargo_doc.rs +++ b/src/cargo/ops/cargo_doc.rs @@ -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 { @@ -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()), + )?; } } } diff --git a/src/cargo/util/config/mod.rs b/src/cargo/util/config/mod.rs index 039cf7cd04f0..3ed2bc52ecd3 100644 --- a/src/cargo/util/config/mod.rs +++ b/src/cargo/util/config/mod.rs @@ -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; @@ -2560,6 +2563,7 @@ struct TermConfig { verbose: Option, quiet: Option, color: Option, + hyperlinks: Option, #[serde(default)] #[serde(deserialize_with = "progress_or_string")] progress: Option, diff --git a/src/cargo/util/hostname.rs b/src/cargo/util/hostname.rs new file mode 100644 index 000000000000..be312cd788aa --- /dev/null +++ b/src/cargo/util/hostname.rs @@ -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 { + #[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 { + 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::(), 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()); + } +} diff --git a/src/cargo/util/mod.rs b/src/cargo/util/mod.rs index e73dc42be1f7..06873159c315 100644 --- a/src/cargo/util/mod.rs +++ b/src/cargo/util/mod.rs @@ -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; @@ -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; diff --git a/src/doc/src/reference/config.md b/src/doc/src/reference/config.md index 9ef42f68f4d7..25e17ac907f6 100644 --- a/src/doc/src/reference/config.md +++ b/src/doc/src/reference/config.md @@ -180,6 +180,7 @@ metadata_key2 = "value" quiet = false # whether cargo output is quiet verbose = false # whether cargo provides verbose output color = 'auto' # whether cargo colorizes output +hyperlinks = true # whether cargo inserts links into output progress.when = 'auto' # whether cargo shows progress bar progress.width = 80 # width of progress bar ``` @@ -1271,6 +1272,13 @@ Controls whether or not colored output is used in the terminal. Possible values: Can be overridden with the `--color` command-line option. +#### `term.hyperlinks` +* Type: bool +* Default: auto-detect +* Environment: `CARGO_TERM_HYPERLINKS` + +Controls whether or not hyperlinks are used in the terminal. + #### `term.progress.when` * Type: string * Default: "auto"