Skip to content

Commit 34d82e5

Browse files
committed
feat: Make browser links out of HTML file paths
This provides an alternative to `--open`, where supported. Fixes #12888
1 parent 708383d commit 34d82e5

File tree

9 files changed

+225
-12
lines changed

9 files changed

+225
-12
lines changed

Cargo.lock

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ serde_json = "1.0.107"
8585
sha1 = "0.10.6"
8686
sha2 = "0.10.8"
8787
shell-escape = "0.1.5"
88+
supports-hyperlinks = "2.1.0"
8889
snapbox = { version = "0.4.13", features = ["diff", "path"] }
8990
syn = { version = "2.0.37", features = ["extra-traits", "full"] }
9091
tar = { version = "0.4.40", default-features = false }
@@ -173,6 +174,7 @@ serde_ignored.workspace = true
173174
serde_json = { workspace = true, features = ["raw_value"] }
174175
sha1.workspace = true
175176
shell-escape.workspace = true
177+
supports-hyperlinks.workspace = true
176178
syn.workspace = true
177179
tar.workspace = true
178180
tempfile.workspace = true

src/cargo/core/compiler/timings.rs

+13-10
Original file line numberDiff line numberDiff line change
@@ -339,18 +339,21 @@ impl<'cfg> Timings<'cfg> {
339339
include_str!("timings.js")
340340
)?;
341341
drop(f);
342-
let msg = format!(
343-
"report saved to {}",
344-
std::env::current_dir()
345-
.unwrap_or_default()
346-
.join(&filename)
347-
.display()
348-
);
342+
349343
let unstamped_filename = timings_path.join("cargo-timing.html");
350344
paths::link_or_copy(&filename, &unstamped_filename)?;
351-
self.config
352-
.shell()
353-
.status_with_color("Timing", msg, &style::NOTE)?;
345+
346+
let mut shell = self.config.shell();
347+
let timing_path = std::env::current_dir().unwrap_or_default().join(&filename);
348+
let link = shell.err_file_hyperlink(&timing_path);
349+
let msg = format!(
350+
"report saved to {}{}{}",
351+
link.open(),
352+
timing_path.display(),
353+
link.close()
354+
);
355+
shell.status_with_color("Timing", msg, &style::NOTE)?;
356+
354357
Ok(())
355358
}
356359

src/cargo/core/shell.rs

+101
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use anstream::AutoStream;
66
use anstyle::Style;
77

88
use crate::util::errors::CargoResult;
9+
use crate::util::hostname;
910
use crate::util::style::*;
1011

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

6264
impl fmt::Debug for Shell {
@@ -85,6 +87,7 @@ enum ShellOut {
8587
stderr: AutoStream<std::io::Stderr>,
8688
stderr_tty: bool,
8789
color_choice: ColorChoice,
90+
hyperlinks: bool,
8891
},
8992
}
9093

@@ -111,10 +114,12 @@ impl Shell {
111114
stdout: AutoStream::new(std::io::stdout(), stdout_choice),
112115
stderr: AutoStream::new(std::io::stderr(), stderr_choice),
113116
color_choice: auto_clr,
117+
hyperlinks: supports_hyperlinks::supports_hyperlinks(),
114118
stderr_tty: std::io::stderr().is_terminal(),
115119
},
116120
verbosity: Verbosity::Verbose,
117121
needs_clear: false,
122+
hostname: None,
118123
}
119124
}
120125

@@ -124,6 +129,7 @@ impl Shell {
124129
output: ShellOut::Write(AutoStream::never(out)), // strip all formatting on write
125130
verbosity: Verbosity::Verbose,
126131
needs_clear: false,
132+
hostname: None,
127133
}
128134
}
129135

@@ -314,6 +320,16 @@ impl Shell {
314320
Ok(())
315321
}
316322

323+
pub fn set_hyperlinks(&mut self, yes: bool) -> CargoResult<()> {
324+
if let ShellOut::Stream {
325+
ref mut hyperlinks, ..
326+
} = self.output
327+
{
328+
*hyperlinks = yes;
329+
}
330+
Ok(())
331+
}
332+
317333
/// Gets the current color choice.
318334
///
319335
/// If we are not using a color stream, this will always return `Never`, even if the color
@@ -340,6 +356,63 @@ impl Shell {
340356
}
341357
}
342358

359+
pub fn out_hyperlink<D: fmt::Display>(&self, url: D) -> Hyperlink<D> {
360+
let supports_hyperlinks = match &self.output {
361+
ShellOut::Write(_) => false,
362+
ShellOut::Stream {
363+
stdout, hyperlinks, ..
364+
} => stdout.current_choice() == anstream::ColorChoice::AlwaysAnsi && *hyperlinks,
365+
};
366+
if supports_hyperlinks {
367+
Hyperlink { url: Some(url) }
368+
} else {
369+
Hyperlink { url: None }
370+
}
371+
}
372+
373+
pub fn err_hyperlink<D: fmt::Display>(&self, url: D) -> Hyperlink<D> {
374+
let supports_hyperlinks = match &self.output {
375+
ShellOut::Write(_) => false,
376+
ShellOut::Stream {
377+
stderr, hyperlinks, ..
378+
} => stderr.current_choice() == anstream::ColorChoice::AlwaysAnsi && *hyperlinks,
379+
};
380+
if supports_hyperlinks {
381+
Hyperlink { url: Some(url) }
382+
} else {
383+
Hyperlink { url: None }
384+
}
385+
}
386+
387+
pub fn out_file_hyperlink(&mut self, path: &std::path::Path) -> Hyperlink<url::Url> {
388+
let url = self.file_hyperlink(path);
389+
url.map(|u| self.out_hyperlink(u)).unwrap_or_default()
390+
}
391+
392+
pub fn err_file_hyperlink(&mut self, path: &std::path::Path) -> Hyperlink<url::Url> {
393+
let url = self.file_hyperlink(path);
394+
url.map(|u| self.err_hyperlink(u)).unwrap_or_default()
395+
}
396+
397+
fn file_hyperlink(&mut self, path: &std::path::Path) -> Option<url::Url> {
398+
let mut url = url::Url::from_file_path(path).ok()?;
399+
// Do a best-effort of setting the host in the URL to avoid issues with opening a link
400+
// scoped to the computer you've SSHed into
401+
let hostname = if cfg!(windows) {
402+
// Not supported correctly on windows
403+
None
404+
} else {
405+
if let Some(hostname) = self.hostname.as_deref() {
406+
Some(hostname)
407+
} else {
408+
self.hostname = hostname().ok().and_then(|h| h.into_string().ok());
409+
self.hostname.as_deref()
410+
}
411+
};
412+
let _ = url.set_host(hostname);
413+
Some(url)
414+
}
415+
343416
/// Prints a message to stderr and translates ANSI escape code into console colors.
344417
pub fn print_ansi_stderr(&mut self, message: &[u8]) -> CargoResult<()> {
345418
if self.needs_clear {
@@ -439,6 +512,34 @@ fn supports_color(choice: anstream::ColorChoice) -> bool {
439512
}
440513
}
441514

515+
pub struct Hyperlink<D: fmt::Display> {
516+
url: Option<D>,
517+
}
518+
519+
impl<D: fmt::Display> Default for Hyperlink<D> {
520+
fn default() -> Self {
521+
Self { url: None }
522+
}
523+
}
524+
525+
impl<D: fmt::Display> Hyperlink<D> {
526+
pub fn open(&self) -> impl fmt::Display {
527+
if let Some(url) = self.url.as_ref() {
528+
itertools::Either::Left(format!("\x1B]8;;{url}\x1B\\"))
529+
} else {
530+
itertools::Either::Right("")
531+
}
532+
}
533+
534+
pub fn close(&self) -> impl fmt::Display {
535+
if self.url.is_some() {
536+
itertools::Either::Left("\x1B]8;;\x1B\\")
537+
} else {
538+
itertools::Either::Right("")
539+
}
540+
}
541+
}
542+
442543
#[cfg(unix)]
443544
mod imp {
444545
use super::{Shell, TtyWidth};

src/cargo/ops/cargo_doc.rs

+10-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ pub fn doc(ws: &Workspace<'_>, options: &DocOptions) -> CargoResult<()> {
3535
cfg.map(|path_args| (path_args.path.resolve_program(ws.config()), path_args.args))
3636
};
3737
let mut shell = ws.config().shell();
38-
shell.status("Opening", path.display())?;
38+
let link = shell.err_file_hyperlink(&path);
39+
shell.status(
40+
"Opening",
41+
format!("{}{}{}", link.open(), path.display(), link.close()),
42+
)?;
3943
open_docs(&path, &mut shell, config_browser, ws.config())?;
4044
}
4145
} else {
@@ -47,7 +51,11 @@ pub fn doc(ws: &Workspace<'_>, options: &DocOptions) -> CargoResult<()> {
4751
.join("index.html");
4852
if path.exists() {
4953
let mut shell = ws.config().shell();
50-
shell.status("Generated", path.display())?;
54+
let link = shell.err_file_hyperlink(&path);
55+
shell.status(
56+
"Generated",
57+
format!("{}{}{}", link.open(), path.display(), link.close()),
58+
)?;
5159
}
5260
}
5361
}

src/cargo/util/config/mod.rs

+4
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,9 @@ impl Config {
10321032

10331033
self.shell().set_verbosity(verbosity);
10341034
self.shell().set_color_choice(color)?;
1035+
if let Some(hyperlinks) = term.hyperlinks {
1036+
self.shell().set_hyperlinks(hyperlinks)?;
1037+
}
10351038
self.progress_config = term.progress.unwrap_or_default();
10361039
self.extra_verbose = extra_verbose;
10371040
self.frozen = frozen;
@@ -2560,6 +2563,7 @@ struct TermConfig {
25602563
verbose: Option<bool>,
25612564
quiet: Option<bool>,
25622565
color: Option<String>,
2566+
hyperlinks: Option<bool>,
25632567
#[serde(default)]
25642568
#[serde(deserialize_with = "progress_or_string")]
25652569
progress: Option<ProgressConfig>,

src/cargo/util/hostname.rs

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
use std::{ffi::OsString, io};
2+
3+
/// Returns the hostname of the current system.
4+
///
5+
/// It is unusual, although technically possible, for this routine to return
6+
/// an error. It is difficult to list out the error conditions, but one such
7+
/// possibility is platform support.
8+
///
9+
/// # Platform specific behavior
10+
///
11+
/// On Unix, this returns the result of the `gethostname` function from the
12+
/// `libc` linked into the program.
13+
pub fn hostname() -> io::Result<OsString> {
14+
#[cfg(unix)]
15+
{
16+
gethostname()
17+
}
18+
#[cfg(not(unix))]
19+
{
20+
Err(io::Error::new(
21+
io::ErrorKind::Other,
22+
"hostname could not be found on unsupported platform",
23+
))
24+
}
25+
}
26+
27+
#[cfg(unix)]
28+
fn gethostname() -> io::Result<OsString> {
29+
use std::os::unix::ffi::OsStringExt;
30+
31+
// SAFETY: There don't appear to be any safety requirements for calling
32+
// sysconf.
33+
let limit = unsafe { libc::sysconf(libc::_SC_HOST_NAME_MAX) };
34+
if limit == -1 {
35+
// It is in theory possible for sysconf to return -1 for a limit but
36+
// *not* set errno, in which case, io::Error::last_os_error is
37+
// indeterminate. But untangling that is super annoying because std
38+
// doesn't expose any unix-specific APIs for inspecting the errno. (We
39+
// could do it ourselves, but it just doesn't seem worth doing?)
40+
return Err(io::Error::last_os_error());
41+
}
42+
let Ok(maxlen) = usize::try_from(limit) else {
43+
let msg = format!("host name max limit ({}) overflowed usize", limit);
44+
return Err(io::Error::new(io::ErrorKind::Other, msg));
45+
};
46+
// maxlen here includes the NUL terminator.
47+
let mut buf = vec![0; maxlen];
48+
// SAFETY: The pointer we give is valid as it is derived directly from a
49+
// Vec. Similarly, `maxlen` is the length of our Vec, and is thus valid
50+
// to write to.
51+
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast::<libc::c_char>(), maxlen) };
52+
if rc == -1 {
53+
return Err(io::Error::last_os_error());
54+
}
55+
// POSIX says that if the hostname is bigger than `maxlen`, then it may
56+
// write a truncate name back that is not necessarily NUL terminated (wtf,
57+
// lol). So if we can't find a NUL terminator, then just give up.
58+
let Some(zeropos) = buf.iter().position(|&b| b == 0) else {
59+
let msg = "could not find NUL terminator in hostname";
60+
return Err(io::Error::new(io::ErrorKind::Other, msg));
61+
};
62+
buf.truncate(zeropos);
63+
buf.shrink_to_fit();
64+
Ok(OsString::from_vec(buf))
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
#[test]
72+
fn print_hostname() {
73+
println!("{:?}", hostname().unwrap());
74+
}
75+
}

src/cargo/util/mod.rs

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub use self::flock::{FileLock, Filesystem};
1414
pub use self::graph::Graph;
1515
pub use self::hasher::StableHasher;
1616
pub use self::hex::{hash_u64, short_hash, to_hex};
17+
pub use self::hostname::hostname;
1718
pub use self::into_url::IntoUrl;
1819
pub use self::into_url_with_base::IntoUrlWithBase;
1920
pub(crate) use self::io::LimitErrorReader;
@@ -46,6 +47,7 @@ mod flock;
4647
pub mod graph;
4748
mod hasher;
4849
pub mod hex;
50+
mod hostname;
4951
pub mod important_paths;
5052
pub mod interning;
5153
pub mod into_url;

src/doc/src/reference/config.md

+8
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ metadata_key2 = "value"
180180
quiet = false # whether cargo output is quiet
181181
verbose = false # whether cargo provides verbose output
182182
color = 'auto' # whether cargo colorizes output
183+
hyperlinks = true # whether cargo inserts links into output
183184
progress.when = 'auto' # whether cargo shows progress bar
184185
progress.width = 80 # width of progress bar
185186
```
@@ -1271,6 +1272,13 @@ Controls whether or not colored output is used in the terminal. Possible values:
12711272

12721273
Can be overridden with the `--color` command-line option.
12731274

1275+
#### `term.hyperlinks`
1276+
* Type: bool
1277+
* Default: auto-detect
1278+
* Environment: `CARGO_TERM_HYPERLINKS`
1279+
1280+
Controls whether or not hyperlinks are used in the terminal.
1281+
12741282
#### `term.progress.when`
12751283
* Type: string
12761284
* Default: "auto"

0 commit comments

Comments
 (0)